@gallopsystems/agent-skills 1.0.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.
- package/README.md +137 -0
- package/package.json +26 -0
- package/plugins/doctl/.claude-plugin/plugin.json +8 -0
- package/plugins/doctl/skills/doctl/SKILL.md +93 -0
- package/plugins/kysely-postgres/.claude-plugin/plugin.json +8 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/SKILL.md +1101 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/aggregations.ts +167 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/ctes.ts +165 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/expressions.ts +272 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/joins.ts +206 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/json-arrays.ts +398 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/mutations.ts +199 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/orderby-pagination.ts +117 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/relations.ts +176 -0
- package/plugins/kysely-postgres/skills/kysely-postgres/references/select-where.ts +146 -0
- package/plugins/linear/.claude-plugin/plugin.json +8 -0
- package/plugins/linear/skills/linear/SKILL.md +1040 -0
- package/plugins/linear/skills/linear/bin/linear.mjs +1228 -0
- package/plugins/linear/skills/linear/tech-stack.md +273 -0
- package/plugins/nitro-testing/.claude-plugin/plugin.json +8 -0
- package/plugins/nitro-testing/skills/nitro-testing/SKILL.md +497 -0
- package/plugins/nitro-testing/skills/nitro-testing/async-testing.md +270 -0
- package/plugins/nitro-testing/skills/nitro-testing/ci-setup.md +226 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/global-setup.ts +90 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/handler.test.ts +167 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/setup.ts +29 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/test-utils-index.ts +297 -0
- package/plugins/nitro-testing/skills/nitro-testing/examples/vitest.config.ts +42 -0
- package/plugins/nitro-testing/skills/nitro-testing/factories.md +278 -0
- package/plugins/nitro-testing/skills/nitro-testing/frontend-testing.md +512 -0
- package/plugins/nitro-testing/skills/nitro-testing/test-utils.md +262 -0
- package/plugins/nitro-testing/skills/nitro-testing/transaction-rollback.md +183 -0
- package/plugins/nitro-testing/skills/nitro-testing/vitest-config.md +236 -0
- package/plugins/nuxt-nitro-api/.claude-plugin/plugin.json +8 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/SKILL.md +260 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/auth-patterns.md +228 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/composables-utils.md +174 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/deep-linking.md +190 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-middleware.ts +32 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/auth-utils.ts +51 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/deep-link-page.vue +61 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/service-util.ts +63 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/sse-endpoint.ts +59 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/examples/validation-endpoint.ts +38 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/fetch-patterns.md +178 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/nitro-tasks.md +243 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/page-structure.md +162 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/server-services.md +238 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/sse.md +221 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/ssr-client.md +166 -0
- package/plugins/nuxt-nitro-api/skills/nuxt-nitro-api/validation.md +131 -0
- package/scripts/link-skills.mjs +252 -0
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: nuxt-nitro-api
|
|
3
|
+
description: Build type-safe Nuxt 3 applications with Nitro API patterns. Covers validation, fetch patterns, auth, SSR, composables, background tasks, and real-time features.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Nuxt 3 / Nitro API Patterns
|
|
7
|
+
|
|
8
|
+
This skill provides patterns for building type-safe Nuxt 3 applications with Nitro backends.
|
|
9
|
+
|
|
10
|
+
## When to Use This Skill
|
|
11
|
+
|
|
12
|
+
Use this skill when:
|
|
13
|
+
- Working in a Nuxt 3 project with TypeScript
|
|
14
|
+
- Building API endpoints with Nitro
|
|
15
|
+
- Implementing authentication with nuxt-auth-utils
|
|
16
|
+
- Handling SSR + client-side state
|
|
17
|
+
- Creating background tasks or real-time features
|
|
18
|
+
|
|
19
|
+
## Reference Files
|
|
20
|
+
|
|
21
|
+
For detailed patterns, see these topic-focused reference files:
|
|
22
|
+
|
|
23
|
+
- [validation.md](./validation.md) - Zod validation with h3, Standard Schema, error handling
|
|
24
|
+
- [fetch-patterns.md](./fetch-patterns.md) - useFetch vs $fetch vs useAsyncData
|
|
25
|
+
- [auth-patterns.md](./auth-patterns.md) - nuxt-auth-utils, OAuth, WebAuthn, middleware
|
|
26
|
+
- [page-structure.md](./page-structure.md) - Keep pages thin, components do the work
|
|
27
|
+
- [composables-utils.md](./composables-utils.md) - When to use composables vs utils
|
|
28
|
+
- [ssr-client.md](./ssr-client.md) - SSR + localStorage, hydration, VueUse
|
|
29
|
+
- [deep-linking.md](./deep-linking.md) - URL params sync with filters and useFetch
|
|
30
|
+
- [nitro-tasks.md](./nitro-tasks.md) - Background jobs, scheduled tasks, job queues
|
|
31
|
+
- [sse.md](./sse.md) - Server-Sent Events for real-time streaming
|
|
32
|
+
- [server-services.md](./server-services.md) - Third-party service integration patterns
|
|
33
|
+
|
|
34
|
+
## Example Files
|
|
35
|
+
|
|
36
|
+
Working examples from a Nuxt project:
|
|
37
|
+
|
|
38
|
+
- [validation-endpoint.ts](./examples/validation-endpoint.ts) - API endpoint with Zod validation
|
|
39
|
+
- [auth-middleware.ts](./examples/auth-middleware.ts) - Server auth middleware
|
|
40
|
+
- [auth-utils.ts](./examples/auth-utils.ts) - Reusable auth helpers
|
|
41
|
+
- [deep-link-page.vue](./examples/deep-link-page.vue) - URL params sync with filters
|
|
42
|
+
- [sse-endpoint.ts](./examples/sse-endpoint.ts) - SSE streaming endpoint
|
|
43
|
+
- [service-util.ts](./examples/service-util.ts) - Server-side service pattern
|
|
44
|
+
|
|
45
|
+
## Core Principles
|
|
46
|
+
|
|
47
|
+
1. **Let Nitro infer types** - Never add manual type params to `$fetch<Type>()` or `useFetch<Type>()`
|
|
48
|
+
2. **Use h3 validation** - `getValidatedQuery()`, `readValidatedBody()` with Zod schemas
|
|
49
|
+
3. **Composables for context, utils for pure functions** - Composables access Nuxt context, utils are pure
|
|
50
|
+
4. **SSR-safe code** - Guard browser APIs with `import.meta.client` or `onMounted`
|
|
51
|
+
5. **Keep pages thin** - Pages = layout + route params + components. Components own data fetching and logic.
|
|
52
|
+
|
|
53
|
+
## Auto-Imports Quick Reference
|
|
54
|
+
|
|
55
|
+
### Server-side (`/server` directory)
|
|
56
|
+
|
|
57
|
+
All h3 utilities auto-imported:
|
|
58
|
+
- `defineEventHandler`, `createError`, `getQuery`, `getValidatedQuery`
|
|
59
|
+
- `readBody`, `readValidatedBody`, `getRouterParams`, `getValidatedRouterParams`
|
|
60
|
+
- `getCookie`, `setCookie`, `deleteCookie`, `getHeader`, `setHeader`
|
|
61
|
+
|
|
62
|
+
From nuxt-auth-utils:
|
|
63
|
+
- `getUserSession`, `setUserSession`, `clearUserSession`, `requireUserSession`
|
|
64
|
+
- `hashPassword`, `verifyPassword`
|
|
65
|
+
- `defineOAuth*EventHandler` (Google, GitHub, etc.)
|
|
66
|
+
|
|
67
|
+
**Need to import:** `z` from "zod", `fromZodError` from "zod-validation-error"
|
|
68
|
+
|
|
69
|
+
### Client-side
|
|
70
|
+
|
|
71
|
+
All auto-imported:
|
|
72
|
+
- Vue: `ref`, `computed`, `watch`, `onMounted`, etc.
|
|
73
|
+
- VueUse: `refDebounced`, `useLocalStorage`, `useUrlSearchParams`, etc.
|
|
74
|
+
- Nuxt: `useFetch`, `useAsyncData`, `useRoute`, `useRouter`, `useState`, `navigateTo`
|
|
75
|
+
|
|
76
|
+
### Shared (`/shared` directory - Nuxt 3.14+)
|
|
77
|
+
|
|
78
|
+
Code auto-imported on both client AND server. Use for:
|
|
79
|
+
- Types and interfaces
|
|
80
|
+
- Pure utility functions
|
|
81
|
+
- Constants
|
|
82
|
+
|
|
83
|
+
## Quick Patterns
|
|
84
|
+
|
|
85
|
+
### Validation (h3 v2+ with Standard Schema)
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
// Pass Zod schema directly (h3 v2+)
|
|
89
|
+
const query = await getValidatedQuery(event, z.object({
|
|
90
|
+
search: z.string().optional(),
|
|
91
|
+
page: z.coerce.number().default(1),
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
const body = await readValidatedBody(event, z.object({
|
|
95
|
+
email: z.string().email(),
|
|
96
|
+
name: z.string().min(1),
|
|
97
|
+
}));
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
### $fetch Type Inference
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// Template literals preserve type inference (fixed late 2024)
|
|
104
|
+
const userId = "123"; // Literal type "123"
|
|
105
|
+
const result = await $fetch(`/api/users/${userId}`);
|
|
106
|
+
// result is typed from the handler's return type
|
|
107
|
+
|
|
108
|
+
// NEVER do this - defeats type inference
|
|
109
|
+
const result = await $fetch<User>("/api/users/123"); // WRONG
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### useFetch for Page Data
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// Basic - types inferred from Nitro
|
|
116
|
+
const { data, status, refresh } = await useFetch("/api/users");
|
|
117
|
+
|
|
118
|
+
// Reactive query params - auto-refetch on change
|
|
119
|
+
const search = ref("");
|
|
120
|
+
const debouncedSearch = refDebounced(search, 300); // Auto-imported
|
|
121
|
+
const { data } = await useFetch("/api/users", {
|
|
122
|
+
query: computed(() => ({
|
|
123
|
+
...(debouncedSearch.value ? { search: debouncedSearch.value } : {}),
|
|
124
|
+
})),
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Dynamic URL with getter
|
|
128
|
+
const userId = ref("123");
|
|
129
|
+
const { data } = await useFetch(() => `/api/users/${userId.value}`);
|
|
130
|
+
|
|
131
|
+
// New options (Nuxt 3.14+)
|
|
132
|
+
const { data } = await useFetch("/api/data", {
|
|
133
|
+
retry: 3, // Retry on failure
|
|
134
|
+
retryDelay: 1000, // Wait between retries
|
|
135
|
+
dedupe: "cancel", // Cancel previous request
|
|
136
|
+
delay: 300, // Debounce the request
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### $fetch for Event Handlers
|
|
141
|
+
|
|
142
|
+
```typescript
|
|
143
|
+
// ONLY use $fetch in event handlers (onClick, onSubmit)
|
|
144
|
+
const handleSubmit = async () => {
|
|
145
|
+
const result = await $fetch("/api/users", {
|
|
146
|
+
method: "POST",
|
|
147
|
+
body: { name: "Test" },
|
|
148
|
+
});
|
|
149
|
+
};
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Auth Check in API
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// In server/utils/auth.ts
|
|
156
|
+
export async function getAuthenticatedUser(event: H3Event) {
|
|
157
|
+
const session = await getUserSession(event);
|
|
158
|
+
if (!session?.user) {
|
|
159
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
160
|
+
}
|
|
161
|
+
return session.user;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// In API handler
|
|
165
|
+
export default defineEventHandler(async (event) => {
|
|
166
|
+
const user = await getAuthenticatedUser(event);
|
|
167
|
+
// user is typed and guaranteed to exist
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### SSR-Safe localStorage
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
// Option 1: import.meta.client guard
|
|
175
|
+
watch(preference, (value) => {
|
|
176
|
+
if (import.meta.client) {
|
|
177
|
+
localStorage.setItem("pref", value);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Option 2: onMounted
|
|
182
|
+
onMounted(() => {
|
|
183
|
+
const saved = localStorage.getItem("pref");
|
|
184
|
+
if (saved) preference.value = saved;
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// Option 3: VueUse (SSR-safe)
|
|
188
|
+
const theme = useLocalStorage("theme", "light");
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### Composable vs Util Decision
|
|
192
|
+
|
|
193
|
+
```
|
|
194
|
+
Needs Nuxt/Vue context (useRuntimeConfig, useRoute, refs)?
|
|
195
|
+
├─ YES → COMPOSABLE in /composables/use*.ts
|
|
196
|
+
└─ NO → UTIL in /utils/*.ts (client) or /server/utils/*.ts (server)
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Key Gotchas
|
|
200
|
+
|
|
201
|
+
1. **Don't use `$fetch` at top level** - Causes double-fetch (SSR + client). Use `useFetch`.
|
|
202
|
+
2. **Debounce search inputs** - Use `refDebounced` to avoid excessive API calls.
|
|
203
|
+
3. **Reset pagination on filter change** - Or users see empty page 5 with new filters.
|
|
204
|
+
4. **Guard browser APIs** - Use `import.meta.client`, `onMounted`, or `<ClientOnly>`.
|
|
205
|
+
5. **Nitro tasks are single-instance** - Can't run same task twice concurrently. Use DB job queue.
|
|
206
|
+
6. **useRouteQuery needs Nuxt composables** - Pass `route` and `router` explicitly.
|
|
207
|
+
7. **Input types aren't auto-generated** - Export Zod schemas for client use.
|
|
208
|
+
8. **Cookie size limit is 4096 bytes** - Store only essential session data.
|
|
209
|
+
9. **Ambiguous routes need type assertion** - See below.
|
|
210
|
+
10. **Never use generic type params with useFetch/$fetch** - See below.
|
|
211
|
+
|
|
212
|
+
### Ambiguous Route Type Inference
|
|
213
|
+
|
|
214
|
+
Nuxt generates types in `.nuxt/types/nitro-routes.d.ts` with an `InternalApi` object keyed by route paths. When routes overlap, Nuxt can't infer types from template literals:
|
|
215
|
+
|
|
216
|
+
```typescript
|
|
217
|
+
// Routes: GET /api/projects and GET /api/projects/:id
|
|
218
|
+
// If route.params.id is "", the path matches BOTH routes
|
|
219
|
+
const { data } = await useFetch(`/api/projects/${route.params.id}`);
|
|
220
|
+
// data type: unknown (ambiguous)
|
|
221
|
+
|
|
222
|
+
// Fix: Assert the specific route pattern
|
|
223
|
+
const { data } = await useFetch(`/api/projects/${route.params.id}` as '/api/projects/:id');
|
|
224
|
+
// data type: correctly inferred from /api/projects/:id handler
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
### Extracting Types from useFetch (Never Use Generic Params)
|
|
228
|
+
|
|
229
|
+
Never pass type parameters to `useFetch` or `$fetch`:
|
|
230
|
+
|
|
231
|
+
```typescript
|
|
232
|
+
// WRONG - Lies to type checker, breaks when endpoint changes
|
|
233
|
+
const { data } = await useFetch<Project[]>('/api/projects');
|
|
234
|
+
|
|
235
|
+
// RIGHT - Let Nuxt infer from the actual endpoint
|
|
236
|
+
const { data: projects } = await useFetch('/api/projects');
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
To use the inferred type elsewhere in your component:
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
const { data: projects } = await useFetch('/api/projects');
|
|
243
|
+
|
|
244
|
+
// Get the full ref type (Ref<Project[] | null>)
|
|
245
|
+
type ProjectsRef = typeof projects;
|
|
246
|
+
|
|
247
|
+
// Get a single item type from an array response
|
|
248
|
+
type Project = NonNullable<typeof projects.value>[number];
|
|
249
|
+
|
|
250
|
+
// Use in functions/computeds
|
|
251
|
+
function formatProject(project: Project) {
|
|
252
|
+
return `${project.name} - ${project.status}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const activeProjects = computed(() =>
|
|
256
|
+
projects.value?.filter(p => p.status === 'active') ?? []
|
|
257
|
+
);
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
This ensures your frontend types stay in sync with your API - if the endpoint return type changes, TypeScript will catch mismatches.
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Auth Patterns (nuxt-auth-utils)
|
|
2
|
+
|
|
3
|
+
> **Examples:** [auth-utils.ts](./examples/auth-utils.ts), [auth-middleware.ts](./examples/auth-middleware.ts)
|
|
4
|
+
|
|
5
|
+
nuxt-auth-utils supports 40+ OAuth providers and includes WebAuthn (passkey) support.
|
|
6
|
+
|
|
7
|
+
## Server-side Functions (auto-imported)
|
|
8
|
+
|
|
9
|
+
| Function | Purpose |
|
|
10
|
+
|----------|---------|
|
|
11
|
+
| `getUserSession(event)` | Get session (null if not logged in) |
|
|
12
|
+
| `setUserSession(event, data)` | Create/update session (merges) |
|
|
13
|
+
| `replaceUserSession(event, data)` | Replace entire session (no merge) |
|
|
14
|
+
| `clearUserSession(event)` | Clear session (logout) |
|
|
15
|
+
| `requireUserSession(event)` | Get session or throw 401 |
|
|
16
|
+
|
|
17
|
+
### Password Utilities
|
|
18
|
+
|
|
19
|
+
| Function | Purpose |
|
|
20
|
+
|----------|---------|
|
|
21
|
+
| `hashPassword(password)` | Hash with scrypt |
|
|
22
|
+
| `verifyPassword(hash, password)` | Verify password |
|
|
23
|
+
| `passwordNeedsRehash(hash)` | Check if rehash needed |
|
|
24
|
+
|
|
25
|
+
## Client-side Composable
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
const {
|
|
29
|
+
ready, // Computed<boolean> - session loaded?
|
|
30
|
+
loggedIn, // Computed<boolean> - is logged in?
|
|
31
|
+
user, // Computed<User | null> - user data
|
|
32
|
+
session, // Ref<Session> - full session
|
|
33
|
+
fetch, // () => Promise<void> - refresh
|
|
34
|
+
clear, // () => Promise<void> - logout
|
|
35
|
+
openInPopup, // (url: string) => void - OAuth popup
|
|
36
|
+
} = useUserSession();
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## OAuth Handler Pattern
|
|
40
|
+
|
|
41
|
+
```typescript
|
|
42
|
+
// server/api/auth/google.get.ts
|
|
43
|
+
export default defineOAuthGoogleEventHandler({
|
|
44
|
+
config: {
|
|
45
|
+
clientId: config.oauth.google.clientId,
|
|
46
|
+
clientSecret: config.oauth.google.clientSecret,
|
|
47
|
+
},
|
|
48
|
+
async onSuccess(event, { user, tokens }) {
|
|
49
|
+
const dbUser = await findOrCreateUser(user.email, user);
|
|
50
|
+
|
|
51
|
+
await setUserSession(event, {
|
|
52
|
+
user: {
|
|
53
|
+
id: dbUser.id,
|
|
54
|
+
email: dbUser.email,
|
|
55
|
+
name: dbUser.name,
|
|
56
|
+
role: dbUser.role,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return sendRedirect(event, dbUser.role === "admin" ? "/dashboard" : "/home");
|
|
61
|
+
},
|
|
62
|
+
onError(event, error) {
|
|
63
|
+
console.error("OAuth error:", error);
|
|
64
|
+
return sendRedirect(event, "/login?error=oauth");
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Client trigger:
|
|
70
|
+
```typescript
|
|
71
|
+
const { openInPopup } = useUserSession();
|
|
72
|
+
const loginWithGoogle = () => openInPopup("/api/auth/google");
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## WebAuthn (Passkeys)
|
|
76
|
+
|
|
77
|
+
```typescript
|
|
78
|
+
// Server: Register credential
|
|
79
|
+
export default defineWebAuthnRegisterEventHandler({
|
|
80
|
+
async onSuccess(event, { credential, user }) {
|
|
81
|
+
await db.insertInto("webauthn_credentials").values({
|
|
82
|
+
user_id: user.id,
|
|
83
|
+
credential_id: credential.id,
|
|
84
|
+
public_key: credential.publicKey,
|
|
85
|
+
}).execute();
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Server: Authenticate
|
|
90
|
+
export default defineWebAuthnAuthenticateEventHandler({
|
|
91
|
+
async getCredential(event, credentialId) {
|
|
92
|
+
return await db.selectFrom("webauthn_credentials")
|
|
93
|
+
.where("credential_id", "=", credentialId)
|
|
94
|
+
.executeTakeFirst();
|
|
95
|
+
},
|
|
96
|
+
async onSuccess(event, { credential, user }) {
|
|
97
|
+
await setUserSession(event, { user });
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
```typescript
|
|
103
|
+
// Client
|
|
104
|
+
const { register, authenticate } = useWebAuthn();
|
|
105
|
+
await register({ userName: user.email });
|
|
106
|
+
await authenticate();
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Server Middleware
|
|
110
|
+
|
|
111
|
+
```typescript
|
|
112
|
+
// server/middleware/auth.ts
|
|
113
|
+
export default defineEventHandler(async (event) => {
|
|
114
|
+
// Skip auth routes
|
|
115
|
+
if (event.path.startsWith("/api/auth")) return;
|
|
116
|
+
|
|
117
|
+
if (event.path.startsWith("/api")) {
|
|
118
|
+
const session = await getUserSession(event);
|
|
119
|
+
if (!session?.user) {
|
|
120
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Role-based restrictions
|
|
124
|
+
if (event.path.startsWith("/api/admin") && session.user.role !== "admin") {
|
|
125
|
+
throw createError({ statusCode: 403, statusMessage: "Forbidden" });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Client Middleware
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
// middleware/auth.global.ts
|
|
135
|
+
export default defineNuxtRouteMiddleware((to) => {
|
|
136
|
+
const { loggedIn, user } = useUserSession();
|
|
137
|
+
const publicRoutes = ["/login", "/signup"];
|
|
138
|
+
|
|
139
|
+
if (!loggedIn.value && !publicRoutes.includes(to.path)) {
|
|
140
|
+
return navigateTo("/login");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (loggedIn.value && to.path === "/login") {
|
|
144
|
+
return navigateTo("/");
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Named middleware:
|
|
150
|
+
```typescript
|
|
151
|
+
// middleware/admin.ts
|
|
152
|
+
export default defineNuxtRouteMiddleware(() => {
|
|
153
|
+
const { loggedIn, user } = useUserSession();
|
|
154
|
+
if (!loggedIn.value || user.value?.role !== "admin") {
|
|
155
|
+
return navigateTo("/");
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
// pages/admin/dashboard.vue
|
|
160
|
+
definePageMeta({ middleware: "admin" });
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Reusable Auth Helpers
|
|
164
|
+
|
|
165
|
+
```typescript
|
|
166
|
+
// server/utils/auth.ts
|
|
167
|
+
export async function getAuthenticatedUser(event: H3Event) {
|
|
168
|
+
const session = await getUserSession(event);
|
|
169
|
+
if (!session?.user) {
|
|
170
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
171
|
+
}
|
|
172
|
+
return session.user;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function requireRole(event: H3Event, roles: string[]) {
|
|
176
|
+
const user = await getAuthenticatedUser(event);
|
|
177
|
+
if (!roles.includes(user.role)) {
|
|
178
|
+
throw createError({ statusCode: 403, statusMessage: "Forbidden" });
|
|
179
|
+
}
|
|
180
|
+
return user;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export async function requireAdmin(event: H3Event) {
|
|
184
|
+
return requireRole(event, ["admin", "superadmin"]);
|
|
185
|
+
}
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
## Type Extension
|
|
189
|
+
|
|
190
|
+
```typescript
|
|
191
|
+
// types/auth.d.ts
|
|
192
|
+
declare module "#auth-utils" {
|
|
193
|
+
interface User {
|
|
194
|
+
id: number;
|
|
195
|
+
email: string;
|
|
196
|
+
name: string;
|
|
197
|
+
role: "admin" | "user";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
interface UserSession {
|
|
201
|
+
loggedInAt: string;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface SecureSessionData {
|
|
205
|
+
internalToken?: string; // Server-only
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
## Configuration
|
|
211
|
+
|
|
212
|
+
```bash
|
|
213
|
+
# Required (32+ chars, auto-generated in dev)
|
|
214
|
+
NUXT_SESSION_PASSWORD=your-super-secret-password-at-least-32-chars
|
|
215
|
+
|
|
216
|
+
# OAuth (per-provider)
|
|
217
|
+
NUXT_OAUTH_GOOGLE_CLIENT_ID=...
|
|
218
|
+
NUXT_OAUTH_GOOGLE_CLIENT_SECRET=...
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Key Gotchas
|
|
222
|
+
|
|
223
|
+
1. **Skip auth routes in middleware** - `/api/auth/*` and `/api/_auth/*`
|
|
224
|
+
2. **Use `openInPopup` for OAuth** - Better UX than redirect
|
|
225
|
+
3. **Cookie size limit is 4096 bytes** - Store only essential data
|
|
226
|
+
4. **setUserSession merges** - Use `replaceUserSession` to replace
|
|
227
|
+
5. **requireUserSession throws** - Use getUserSession for null
|
|
228
|
+
6. **Cannot use with `nuxt generate`** - Requires running server
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# Composables vs Utils
|
|
2
|
+
|
|
3
|
+
## Quick Decision Tree
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
Needs Nuxt/Vue context (useRuntimeConfig, useRoute, refs, toast)?
|
|
7
|
+
├─ YES → COMPOSABLE in /composables/use*.ts
|
|
8
|
+
│
|
|
9
|
+
└─ NO
|
|
10
|
+
└─ Server-side logic (DB, file system, auth)?
|
|
11
|
+
├─ YES → SERVER UTILS in /server/utils/
|
|
12
|
+
│
|
|
13
|
+
└─ NO (Pure data transformation)
|
|
14
|
+
└─ CLIENT UTILS in /utils/
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Composables (`/composables/use*.ts`)
|
|
18
|
+
|
|
19
|
+
**When to use:**
|
|
20
|
+
- Accesses Nuxt/Vue context: `useRuntimeConfig()`, `useRoute()`, `navigateTo()`
|
|
21
|
+
- Uses Vue reactivity: `ref()`, `computed()`, `watch()` (optional!)
|
|
22
|
+
- Accesses global services: `useToast()`, `useUserSession()`
|
|
23
|
+
- Named with `use` prefix (required for auto-import)
|
|
24
|
+
|
|
25
|
+
> **Note:** A composable does NOT need reactivity. If it accesses any Nuxt composable, it's a composable.
|
|
26
|
+
|
|
27
|
+
```typescript
|
|
28
|
+
// composables/useFormState.ts
|
|
29
|
+
export const useFormState = (initialData: FormData) => {
|
|
30
|
+
const data = ref(initialData);
|
|
31
|
+
const isDirty = computed(() =>
|
|
32
|
+
JSON.stringify(data.value) !== JSON.stringify(initialData)
|
|
33
|
+
);
|
|
34
|
+
const errors = ref<Record<string, string>>({});
|
|
35
|
+
const toast = useToast();
|
|
36
|
+
|
|
37
|
+
watch(data, (newValue) => {
|
|
38
|
+
const result = schema.safeParse(newValue);
|
|
39
|
+
errors.value = result.success ? {} : formatErrors(result.error);
|
|
40
|
+
}, { deep: true });
|
|
41
|
+
|
|
42
|
+
const save = async () => {
|
|
43
|
+
try {
|
|
44
|
+
await $fetch("/api/save", { method: "POST", body: data.value });
|
|
45
|
+
toast.add({ severity: "success", summary: "Saved!" });
|
|
46
|
+
} catch (e) {
|
|
47
|
+
toast.add({ severity: "error", summary: "Failed" });
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
return { data, isDirty, errors, save };
|
|
52
|
+
};
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
// composables/usePermissions.ts
|
|
57
|
+
export const usePermissions = () => {
|
|
58
|
+
const { user } = useUserSession();
|
|
59
|
+
|
|
60
|
+
const hasRole = (role: string) => user.value?.role === role;
|
|
61
|
+
const isAdmin = () => hasRole("admin") || hasRole("superadmin");
|
|
62
|
+
|
|
63
|
+
const can = (action: string, resource: string) => {
|
|
64
|
+
if (!user.value) return false;
|
|
65
|
+
if (isAdmin()) return true;
|
|
66
|
+
// User-specific permissions
|
|
67
|
+
return false;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return { hasRole, isAdmin, can };
|
|
71
|
+
};
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Client Utils (`/utils/*.ts`)
|
|
75
|
+
|
|
76
|
+
**When to use:**
|
|
77
|
+
- Pure functions, no side effects
|
|
78
|
+
- No Vue/Nuxt dependencies
|
|
79
|
+
- Data transformations, formatting, parsing
|
|
80
|
+
- NO `use` prefix
|
|
81
|
+
|
|
82
|
+
```typescript
|
|
83
|
+
// utils/formatting.ts
|
|
84
|
+
export const formatDate = (date: string) => {
|
|
85
|
+
return new Date(date).toLocaleDateString("en-US", {
|
|
86
|
+
year: "numeric",
|
|
87
|
+
month: "short",
|
|
88
|
+
day: "numeric",
|
|
89
|
+
});
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export const formatCurrency = (amount: number) => {
|
|
93
|
+
return new Intl.NumberFormat("en-US", {
|
|
94
|
+
style: "currency",
|
|
95
|
+
currency: "USD",
|
|
96
|
+
}).format(amount);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const generateColor = (id: number) => {
|
|
100
|
+
const colors = ["#3B82F6", "#EF4444", "#10B981"];
|
|
101
|
+
return colors[id % colors.length];
|
|
102
|
+
};
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Server Utils (`/server/utils/*.ts`)
|
|
106
|
+
|
|
107
|
+
**When to use:**
|
|
108
|
+
- Server-side only logic
|
|
109
|
+
- Database access
|
|
110
|
+
- Authentication helpers
|
|
111
|
+
- External APIs, file system
|
|
112
|
+
- Auto-imported in `/server` directory
|
|
113
|
+
|
|
114
|
+
```typescript
|
|
115
|
+
// server/utils/db.ts
|
|
116
|
+
import { Kysely, PostgresDialect } from "kysely";
|
|
117
|
+
import pg from "pg";
|
|
118
|
+
|
|
119
|
+
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
|
|
120
|
+
export const db = new Kysely({ dialect: new PostgresDialect({ pool }) });
|
|
121
|
+
|
|
122
|
+
export function useDatabase() {
|
|
123
|
+
return db;
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
// server/utils/auth.ts
|
|
129
|
+
export async function getAuthenticatedUser(event: H3Event) {
|
|
130
|
+
const session = await getUserSession(event);
|
|
131
|
+
if (!session?.user) {
|
|
132
|
+
throw createError({ statusCode: 401, statusMessage: "Unauthorized" });
|
|
133
|
+
}
|
|
134
|
+
return session.user;
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Shared Utils (`/shared/utils/` - Nuxt 3.14+)
|
|
139
|
+
|
|
140
|
+
**When to use:**
|
|
141
|
+
- Code used on BOTH client and server
|
|
142
|
+
- Types, constants, pure functions
|
|
143
|
+
- NO browser APIs, NO server-only code
|
|
144
|
+
|
|
145
|
+
```typescript
|
|
146
|
+
// shared/utils/format.ts
|
|
147
|
+
export function formatCurrency(amount: number) {
|
|
148
|
+
return new Intl.NumberFormat("en-US", {
|
|
149
|
+
style: "currency",
|
|
150
|
+
currency: "USD",
|
|
151
|
+
}).format(amount);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Can be used in both:
|
|
155
|
+
// - /server/api/invoice.get.ts
|
|
156
|
+
// - /pages/invoice.vue
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
## Summary Table
|
|
160
|
+
|
|
161
|
+
| Location | Naming | Vue APIs | Auto-imported | Use Case |
|
|
162
|
+
|----------|--------|----------|---------------|----------|
|
|
163
|
+
| `/composables/` | `use*` | Yes | Yes (client) | Reactive state, global services |
|
|
164
|
+
| `/utils/` | Any | No | Yes (client) | Pure functions, formatting |
|
|
165
|
+
| `/server/utils/` | Any | No | Yes (server) | DB, auth, server logic |
|
|
166
|
+
| `/shared/utils/` | Any | No | Yes (both) | Isomorphic utilities |
|
|
167
|
+
|
|
168
|
+
## Key Gotchas
|
|
169
|
+
|
|
170
|
+
1. **Composables must start with `use`** - Required for auto-import
|
|
171
|
+
2. **Don't use Vue APIs in utils** - Keeps them testable and portable
|
|
172
|
+
3. **Server utils can't use Vue** - Different runtime
|
|
173
|
+
4. **Auto-import scoping** - `/utils` is client-only, `/server/utils` is server-only
|
|
174
|
+
5. **Composables call order matters** - Call at top of `<script setup>`, not in callbacks
|