yaxa-svelte 2.1.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -14
- package/dist/server/endpoint/endpoint.test.d.ts +1 -0
- package/dist/server/endpoint/endpoint.test.js +364 -0
- package/dist/server/endpoint/guards.d.ts +57 -0
- package/dist/server/endpoint/guards.js +153 -0
- package/dist/server/endpoint/index.d.ts +14 -0
- package/dist/server/endpoint/index.js +242 -0
- package/dist/server/endpoint/problem.d.ts +26 -0
- package/dist/server/endpoint/problem.js +62 -0
- package/dist/server/endpoint/types.d.ts +151 -0
- package/dist/server/endpoint/types.js +1 -0
- package/dist/server/endpoint/validate.d.ts +21 -0
- package/dist/server/endpoint/validate.js +128 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +4 -0
- package/dist/server/openapi/generator.d.ts +5 -0
- package/dist/server/openapi/generator.js +317 -0
- package/dist/server/openapi/handler.d.ts +7 -0
- package/dist/server/openapi/handler.js +117 -0
- package/dist/server/openapi/index.d.ts +5 -0
- package/dist/server/openapi/index.js +4 -0
- package/dist/server/openapi/openapi.test.d.ts +1 -0
- package/dist/server/openapi/openapi.test.js +219 -0
- package/dist/server/openapi/registry.d.ts +35 -0
- package/dist/server/openapi/registry.js +51 -0
- package/dist/server/openapi/schema.d.ts +4 -0
- package/dist/server/openapi/schema.js +59 -0
- package/dist/server/openapi/types.d.ts +127 -0
- package/dist/server/openapi/types.js +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -241,22 +241,81 @@ export const POST = createPolarWebhookHandler();
|
|
|
241
241
|
|
|
242
242
|
---
|
|
243
243
|
|
|
244
|
+
## ⚡ Type-Safe Endpoint Pipeline (`yaxaEndpoint` & Declarative Guards)
|
|
245
|
+
|
|
246
|
+
Eliminate endpoint boilerplate in `+server.ts` with declarative auth guards, sliding-window rate limiting, multi-engine validation (Zod, Valibot, Standard Schema), and RFC 7807 Problem Details:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// src/routes/api/projects/+server.ts
|
|
250
|
+
import { yaxaEndpoint, requireAuth, requireRole, rateLimit } from 'yaxa-svelte/server';
|
|
251
|
+
import { z } from 'zod';
|
|
252
|
+
|
|
253
|
+
const CreateProjectSchema = z.object({
|
|
254
|
+
name: z.string().min(3),
|
|
255
|
+
description: z.string().optional()
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
export const POST = yaxaEndpoint({
|
|
259
|
+
status: 201,
|
|
260
|
+
guards: [
|
|
261
|
+
requireAuth(),
|
|
262
|
+
requireRole(['admin', 'owner']),
|
|
263
|
+
rateLimit({ points: 20, windowSeconds: 60 })
|
|
264
|
+
],
|
|
265
|
+
schema: {
|
|
266
|
+
body: CreateProjectSchema
|
|
267
|
+
},
|
|
268
|
+
handle: async ({ body, user, orgId }) => {
|
|
269
|
+
// Guaranteed type-safe body, authenticated user, and active orgId
|
|
270
|
+
return { id: 'proj_123', name: body.name, orgId };
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
---
|
|
276
|
+
|
|
277
|
+
## 📜 Auto-Generated OpenAPI 3.1 & Interactive Docs (`/api/docs`)
|
|
278
|
+
|
|
279
|
+
Instantly generate an OpenAPI 3.1 specification and host interactive documentation ([Scalar](https://scalar.com/), Swagger UI, or Redoc) directly from your `yaxaEndpoint` routes:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
// src/routes/api/docs/+server.ts
|
|
283
|
+
import { createApiDocsHandler } from 'yaxa-svelte/server';
|
|
284
|
+
import { POST as createProject } from '../projects/+server';
|
|
285
|
+
|
|
286
|
+
export const GET = createApiDocsHandler({
|
|
287
|
+
title: 'Acme SaaS API',
|
|
288
|
+
version: '1.0.0',
|
|
289
|
+
theme: 'scalar', // 'scalar' (default) | 'swagger' | 'redoc'
|
|
290
|
+
endpoints: {
|
|
291
|
+
'/api/projects': {
|
|
292
|
+
POST: createProject
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
});
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
- **Interactive Reference UI:** Open `/api/docs` in your browser.
|
|
299
|
+
- **Raw OpenAPI 3.1 JSON:** Request `/api/docs?format=json` or send `Accept: application/json` for SDK generation.
|
|
300
|
+
|
|
301
|
+
---
|
|
302
|
+
|
|
244
303
|
## 📦 Subpath Exports Architecture
|
|
245
304
|
|
|
246
|
-
| Subpath | Description
|
|
247
|
-
| :---------------------- |
|
|
248
|
-
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useToast`, `useClipboard`, etc.)
|
|
249
|
-
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />`
|
|
250
|
-
| `yaxa-svelte/server` | Universal Server Hooks, SEO endpoints, Drizzle DB
|
|
251
|
-
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`)
|
|
252
|
-
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL)
|
|
253
|
-
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization
|
|
254
|
-
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback
|
|
255
|
-
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators
|
|
256
|
-
| `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`)
|
|
257
|
-
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`)
|
|
258
|
-
| `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars
|
|
259
|
-
| `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'`
|
|
305
|
+
| Subpath | Description |
|
|
306
|
+
| :---------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
307
|
+
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useToast`, `useClipboard`, etc.) |
|
|
308
|
+
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
|
|
309
|
+
| `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, OpenAPI 3.1 & Scalar/Swagger Docs, RFC 7807, Universal Server Hooks, SEO endpoints, Drizzle DB, Storage & Email |
|
|
310
|
+
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
|
|
311
|
+
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
|
|
312
|
+
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
|
|
313
|
+
| `yaxa-svelte/email` | Resend transactional email templates with automatic development console fallback |
|
|
314
|
+
| `yaxa-svelte/storage` | AWS S3 and Cloudflare R2 presigned upload URL generators |
|
|
315
|
+
| `yaxa-svelte/testing` | Test mocks & utilities (`createMockSiteConfig`, `createMockUser`, `createTestWrapperProps`) |
|
|
316
|
+
| `yaxa-svelte/vite` | Turnkey Vite plugin (`yaxa()`) |
|
|
317
|
+
| `yaxa-svelte/yaxa.css` | Standalone turnkey Tailwind CSS v4 stylesheet with base styling, `@theme`, and scrollbars |
|
|
318
|
+
| `yaxa-svelte/theme.css` | Pure Tailwind CSS v4 design tokens, `:root` variables, and harmonic radius scale without `@import 'tailwindcss'` |
|
|
260
319
|
|
|
261
320
|
---
|
|
262
321
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createMockRequestEvent } from '../../testing/index.js';
|
|
4
|
+
import { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails, requireAuth, requireRole, rateLimit, composeGuards, validateData } from './index.js';
|
|
5
|
+
describe('RFC 7807 Problem Details', () => {
|
|
6
|
+
it('creates structured problem details object with standard defaults', () => {
|
|
7
|
+
const problem = createProblemDetails({
|
|
8
|
+
status: 404,
|
|
9
|
+
detail: 'Project proj_999 does not exist.'
|
|
10
|
+
});
|
|
11
|
+
expect(problem.status).toBe(404);
|
|
12
|
+
expect(problem.title).toBe('Not Found');
|
|
13
|
+
expect(problem.type).toBe('https://httpstatuses.com/404');
|
|
14
|
+
expect(problem.detail).toBe('Project proj_999 does not exist.');
|
|
15
|
+
expect(isProblemDetails(problem)).toBe(true);
|
|
16
|
+
});
|
|
17
|
+
it('returns Response with application/problem+json content-type and custom fields', async () => {
|
|
18
|
+
const response = problemResponse({
|
|
19
|
+
status: 422,
|
|
20
|
+
title: 'Unprocessable Entity',
|
|
21
|
+
detail: 'Validation error',
|
|
22
|
+
invalidParams: [{ name: 'email', reason: 'Must be a valid email' }],
|
|
23
|
+
traceId: 'trace_abc123'
|
|
24
|
+
});
|
|
25
|
+
expect(response.status).toBe(422);
|
|
26
|
+
expect(response.headers.get('content-type')).toContain('application/problem+json');
|
|
27
|
+
const json = await response.json();
|
|
28
|
+
expect(json.status).toBe(422);
|
|
29
|
+
expect(json.invalidParams).toHaveLength(1);
|
|
30
|
+
expect(json.invalidParams[0].name).toBe('email');
|
|
31
|
+
expect(json.traceId).toBe('trace_abc123');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
describe('Declarative Guards', () => {
|
|
35
|
+
describe('requireAuth', () => {
|
|
36
|
+
it('returns 401 Problem Details when user is unauthenticated', async () => {
|
|
37
|
+
const guard = requireAuth();
|
|
38
|
+
const event = createMockRequestEvent({ locals: { user: null } });
|
|
39
|
+
const result = await guard(event);
|
|
40
|
+
expect(result).toBeInstanceOf(Response);
|
|
41
|
+
if (result instanceof Response) {
|
|
42
|
+
expect(result.status).toBe(401);
|
|
43
|
+
const json = await result.json();
|
|
44
|
+
expect(json.title).toBe('Unauthorized');
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
it('throws 303 redirect when redirect option is set', async () => {
|
|
48
|
+
const guard = requireAuth({ redirect: '/login' });
|
|
49
|
+
const event = createMockRequestEvent({
|
|
50
|
+
url: 'http://localhost:5173/dashboard?tab=billing',
|
|
51
|
+
locals: { user: null }
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
await guard(event);
|
|
55
|
+
expect.fail('Expected redirect to be thrown');
|
|
56
|
+
}
|
|
57
|
+
catch (err) {
|
|
58
|
+
expect(err.status).toBe(303);
|
|
59
|
+
expect(err.location).toContain('/login?returnTo=');
|
|
60
|
+
expect(err.location).toContain(encodeURIComponent('/dashboard?tab=billing'));
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
it('passes cleanly when user exists in locals', async () => {
|
|
64
|
+
const guard = requireAuth();
|
|
65
|
+
const event = createMockRequestEvent({
|
|
66
|
+
locals: { user: { id: 'usr_1', role: 'admin' } }
|
|
67
|
+
});
|
|
68
|
+
const result = await guard(event);
|
|
69
|
+
expect(result).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
describe('requireRole', () => {
|
|
73
|
+
it('returns 401 if user is not logged in', async () => {
|
|
74
|
+
const guard = requireRole(['admin']);
|
|
75
|
+
const event = createMockRequestEvent({ locals: { user: null } });
|
|
76
|
+
const result = (await guard(event));
|
|
77
|
+
expect(result.status).toBe(401);
|
|
78
|
+
});
|
|
79
|
+
it('returns 403 Forbidden when user role is not allowed', async () => {
|
|
80
|
+
const guard = requireRole(['admin', 'owner']);
|
|
81
|
+
const event = createMockRequestEvent({
|
|
82
|
+
locals: { user: { id: 'usr_1', role: 'member' } }
|
|
83
|
+
});
|
|
84
|
+
const result = (await guard(event));
|
|
85
|
+
expect(result.status).toBe(403);
|
|
86
|
+
const json = await result.json();
|
|
87
|
+
expect(json.title).toBe('Forbidden');
|
|
88
|
+
expect(json.requiredRoles).toEqual(['admin', 'owner']);
|
|
89
|
+
});
|
|
90
|
+
it('passes when user has one of the allowed roles', async () => {
|
|
91
|
+
const guard = requireRole(['admin', 'owner']);
|
|
92
|
+
const event = createMockRequestEvent({
|
|
93
|
+
locals: { user: { id: 'usr_1', role: 'owner' } }
|
|
94
|
+
});
|
|
95
|
+
const result = await guard(event);
|
|
96
|
+
expect(result).toBeUndefined();
|
|
97
|
+
});
|
|
98
|
+
it('passes when member role matches org role in multi-tenant setup', async () => {
|
|
99
|
+
const guard = requireRole(['billing_admin']);
|
|
100
|
+
const event = createMockRequestEvent({
|
|
101
|
+
locals: {
|
|
102
|
+
user: { id: 'usr_1', role: 'member' },
|
|
103
|
+
member: { role: 'billing_admin' }
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
const result = await guard(event);
|
|
107
|
+
expect(result).toBeUndefined();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
describe('rateLimit', () => {
|
|
111
|
+
it('allows requests up to points threshold then returns 429', async () => {
|
|
112
|
+
const guard = rateLimit({
|
|
113
|
+
points: 3,
|
|
114
|
+
windowSeconds: 10,
|
|
115
|
+
keyGenerator: () => 'test_client_key'
|
|
116
|
+
});
|
|
117
|
+
const event = createMockRequestEvent();
|
|
118
|
+
// First 3 calls succeed
|
|
119
|
+
expect(await guard(event)).toBeUndefined();
|
|
120
|
+
expect(await guard(event)).toBeUndefined();
|
|
121
|
+
expect(await guard(event)).toBeUndefined();
|
|
122
|
+
// 4th call is rate-limited
|
|
123
|
+
const result = (await guard(event));
|
|
124
|
+
expect(result.status).toBe(429);
|
|
125
|
+
expect(result.headers.get('Retry-After')).toBeDefined();
|
|
126
|
+
expect(result.headers.get('X-RateLimit-Remaining')).toBe('0');
|
|
127
|
+
const json = await result.json();
|
|
128
|
+
expect(json.title).toBe('Too Many Requests');
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
describe('composeGuards', () => {
|
|
132
|
+
it('executes guards sequentially and halts on first failure', async () => {
|
|
133
|
+
const guard = composeGuards(requireAuth(), requireRole(['admin']));
|
|
134
|
+
// Unauthenticated -> fails on requireAuth with 401
|
|
135
|
+
const eventUnauth = createMockRequestEvent({ locals: { user: null } });
|
|
136
|
+
const res1 = (await guard(eventUnauth));
|
|
137
|
+
expect(res1.status).toBe(401);
|
|
138
|
+
// Authenticated but wrong role -> fails on requireRole with 403
|
|
139
|
+
const eventForbidden = createMockRequestEvent({
|
|
140
|
+
locals: { user: { id: 'usr_1', role: 'viewer' } }
|
|
141
|
+
});
|
|
142
|
+
const res2 = (await guard(eventForbidden));
|
|
143
|
+
expect(res2.status).toBe(403);
|
|
144
|
+
// Both pass
|
|
145
|
+
const eventOk = createMockRequestEvent({
|
|
146
|
+
locals: { user: { id: 'usr_1', role: 'admin' } }
|
|
147
|
+
});
|
|
148
|
+
const res3 = await guard(eventOk);
|
|
149
|
+
expect(res3).toBeUndefined();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
describe('Multi-Engine Validation Engine', () => {
|
|
154
|
+
it('validates with Zod schema and extracts parsed data', async () => {
|
|
155
|
+
const schema = z.object({
|
|
156
|
+
email: z.string().email(),
|
|
157
|
+
age: z.number().min(18)
|
|
158
|
+
});
|
|
159
|
+
const valid = await validateData(schema, { email: 'test@example.com', age: 25 });
|
|
160
|
+
expect(valid.success).toBe(true);
|
|
161
|
+
if (valid.success) {
|
|
162
|
+
expect(valid.data.email).toBe('test@example.com');
|
|
163
|
+
expect(valid.data.age).toBe(25);
|
|
164
|
+
}
|
|
165
|
+
const invalid = await validateData(schema, { email: 'bad-email', age: 16 }, 'body');
|
|
166
|
+
expect(invalid.success).toBe(false);
|
|
167
|
+
if (!invalid.success) {
|
|
168
|
+
expect(invalid.invalidParams.length).toBeGreaterThanOrEqual(2);
|
|
169
|
+
expect(invalid.invalidParams[0].name).toContain('body.');
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
it('validates with Standard Schema v1 (~standard)', async () => {
|
|
173
|
+
// Mock Standard Schema specification
|
|
174
|
+
const mockStandardSchema = {
|
|
175
|
+
'~standard': {
|
|
176
|
+
version: 1,
|
|
177
|
+
vendor: 'custom',
|
|
178
|
+
validate: (val) => {
|
|
179
|
+
if (typeof val === 'string' && val.length > 3) {
|
|
180
|
+
return { value: val.toUpperCase() };
|
|
181
|
+
}
|
|
182
|
+
return {
|
|
183
|
+
issues: [{ path: ['name'], message: 'Name must exceed 3 characters' }]
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
const ok = await validateData(mockStandardSchema, 'alice');
|
|
189
|
+
expect(ok.success).toBe(true);
|
|
190
|
+
if (ok.success)
|
|
191
|
+
expect(ok.data).toBe('ALICE');
|
|
192
|
+
const bad = await validateData(mockStandardSchema, 'al', 'body');
|
|
193
|
+
expect(bad.success).toBe(false);
|
|
194
|
+
if (!bad.success) {
|
|
195
|
+
expect(bad.invalidParams[0].name).toBe('body.name');
|
|
196
|
+
expect(bad.invalidParams[0].reason).toContain('exceed 3 characters');
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
it('validates with custom function validator', async () => {
|
|
200
|
+
const validator = (val) => {
|
|
201
|
+
if (!val || val.length === 0) {
|
|
202
|
+
return { success: false, error: 'Cannot be empty' };
|
|
203
|
+
}
|
|
204
|
+
return { success: true, data: val };
|
|
205
|
+
};
|
|
206
|
+
const ok = await validateData(validator, 'hello');
|
|
207
|
+
expect(ok.success).toBe(true);
|
|
208
|
+
const bad = await validateData(validator, '');
|
|
209
|
+
expect(bad.success).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
describe('yaxaEndpoint Higher-Order Pipeline', () => {
|
|
213
|
+
const ItemSchema = z.object({
|
|
214
|
+
title: z.string().min(2),
|
|
215
|
+
price: z.number().positive()
|
|
216
|
+
});
|
|
217
|
+
const QuerySchema = z.object({
|
|
218
|
+
search: z.string().optional()
|
|
219
|
+
});
|
|
220
|
+
it('enforces guards before executing handler', async () => {
|
|
221
|
+
const endpoint = yaxaEndpoint({
|
|
222
|
+
guards: [requireAuth()],
|
|
223
|
+
handle: () => ({ success: true })
|
|
224
|
+
});
|
|
225
|
+
const eventUnauth = createMockRequestEvent({ locals: { user: null } });
|
|
226
|
+
const response = await endpoint(eventUnauth);
|
|
227
|
+
expect(response.status).toBe(401);
|
|
228
|
+
const json = await response.json();
|
|
229
|
+
expect(json.title).toBe('Unauthorized');
|
|
230
|
+
});
|
|
231
|
+
it('returns 422 Problem Details on invalid request body', async () => {
|
|
232
|
+
const endpoint = yaxaEndpoint({
|
|
233
|
+
schema: { body: ItemSchema },
|
|
234
|
+
handle: ({ body }) => body
|
|
235
|
+
});
|
|
236
|
+
const event = createMockRequestEvent({
|
|
237
|
+
method: 'POST',
|
|
238
|
+
body: { title: 'a', price: -5 }
|
|
239
|
+
});
|
|
240
|
+
const response = await endpoint(event);
|
|
241
|
+
expect(response.status).toBe(422);
|
|
242
|
+
expect(response.headers.get('content-type')).toContain('application/problem+json');
|
|
243
|
+
const json = await response.json();
|
|
244
|
+
expect(json.status).toBe(422);
|
|
245
|
+
expect(json.title).toBe('Unprocessable Entity');
|
|
246
|
+
expect(json.invalidParams.length).toBeGreaterThanOrEqual(2);
|
|
247
|
+
});
|
|
248
|
+
it('handles malformed JSON body gracefully with 400 Bad Request', async () => {
|
|
249
|
+
const endpoint = yaxaEndpoint({
|
|
250
|
+
handle: ({ body }) => body
|
|
251
|
+
});
|
|
252
|
+
const event = createMockRequestEvent({
|
|
253
|
+
method: 'POST',
|
|
254
|
+
body: '{ broken json: ',
|
|
255
|
+
headers: { 'content-type': 'application/json' }
|
|
256
|
+
});
|
|
257
|
+
const response = await endpoint(event);
|
|
258
|
+
expect(response.status).toBe(400);
|
|
259
|
+
const json = await response.json();
|
|
260
|
+
expect(json.title).toBe('Bad Request');
|
|
261
|
+
});
|
|
262
|
+
it('executes handler and auto-serializes JSON with typed body, query, and params', async () => {
|
|
263
|
+
const endpoint = yaxaEndpoint({
|
|
264
|
+
status: 201,
|
|
265
|
+
schema: {
|
|
266
|
+
body: ItemSchema,
|
|
267
|
+
query: QuerySchema
|
|
268
|
+
},
|
|
269
|
+
handle: ({ body, query, params, user }) => {
|
|
270
|
+
return {
|
|
271
|
+
created: true,
|
|
272
|
+
item: body,
|
|
273
|
+
q: query.search,
|
|
274
|
+
id: params.id,
|
|
275
|
+
userId: user?.id
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
const event = createMockRequestEvent({
|
|
280
|
+
method: 'POST',
|
|
281
|
+
url: 'http://localhost:5173/api/items/item_42?search=svelte',
|
|
282
|
+
params: { id: 'item_42' },
|
|
283
|
+
body: { title: 'Keyboard', price: 99 },
|
|
284
|
+
locals: { user: { id: 'usr_77', role: 'admin' } }
|
|
285
|
+
});
|
|
286
|
+
const response = await endpoint(event);
|
|
287
|
+
expect(response.status).toBe(201);
|
|
288
|
+
expect(response.headers.get('content-type')).toContain('application/json');
|
|
289
|
+
const json = await response.json();
|
|
290
|
+
expect(json.created).toBe(true);
|
|
291
|
+
expect(json.item.title).toBe('Keyboard');
|
|
292
|
+
expect(json.item.price).toBe(99);
|
|
293
|
+
expect(json.q).toBe('svelte');
|
|
294
|
+
expect(json.id).toBe('item_42');
|
|
295
|
+
expect(json.userId).toBe('usr_77');
|
|
296
|
+
});
|
|
297
|
+
it('supports string returns as text/plain and null/void as 204 No Content', async () => {
|
|
298
|
+
const stringEndpoint = yaxaEndpoint({
|
|
299
|
+
handle: () => 'PONG'
|
|
300
|
+
});
|
|
301
|
+
const res1 = await stringEndpoint(createMockRequestEvent());
|
|
302
|
+
expect(res1.status).toBe(200);
|
|
303
|
+
expect(await res1.text()).toBe('PONG');
|
|
304
|
+
expect(res1.headers.get('content-type')).toContain('text/plain');
|
|
305
|
+
const voidEndpoint = yaxaEndpoint({
|
|
306
|
+
handle: () => undefined
|
|
307
|
+
});
|
|
308
|
+
const res2 = await voidEndpoint(createMockRequestEvent());
|
|
309
|
+
expect(res2.status).toBe(204);
|
|
310
|
+
});
|
|
311
|
+
it('passes through custom Response instances without modification', async () => {
|
|
312
|
+
const customEndpoint = yaxaEndpoint({
|
|
313
|
+
handle: () => new Response('Custom CSV Content', {
|
|
314
|
+
status: 200,
|
|
315
|
+
headers: { 'content-type': 'text/csv' }
|
|
316
|
+
})
|
|
317
|
+
});
|
|
318
|
+
const response = await customEndpoint(createMockRequestEvent());
|
|
319
|
+
expect(response.status).toBe(200);
|
|
320
|
+
expect(response.headers.get('content-type')).toBe('text/csv');
|
|
321
|
+
expect(await response.text()).toBe('Custom CSV Content');
|
|
322
|
+
});
|
|
323
|
+
it('supports interceptors for request timing and header enrichment', async () => {
|
|
324
|
+
const endpoint = yaxaEndpoint({
|
|
325
|
+
interceptors: [
|
|
326
|
+
async (_ctx, next) => {
|
|
327
|
+
const res = await next();
|
|
328
|
+
res.headers.set('x-yaxa-intercepted', 'true');
|
|
329
|
+
return res;
|
|
330
|
+
}
|
|
331
|
+
],
|
|
332
|
+
handle: () => ({ ok: true })
|
|
333
|
+
});
|
|
334
|
+
const response = await endpoint(createMockRequestEvent());
|
|
335
|
+
expect(response.headers.get('x-yaxa-intercepted')).toBe('true');
|
|
336
|
+
});
|
|
337
|
+
it('sanitizes unexpected internal 500 errors without leaking stack traces or sensitive strings', async () => {
|
|
338
|
+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { });
|
|
339
|
+
const failingEndpoint = yaxaEndpoint({
|
|
340
|
+
handle: () => {
|
|
341
|
+
throw new Error('Database password leak: secret123456');
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
const response = await failingEndpoint(createMockRequestEvent());
|
|
345
|
+
expect(response.status).toBe(500);
|
|
346
|
+
expect(response.headers.get('content-type')).toContain('application/problem+json');
|
|
347
|
+
const json = await response.json();
|
|
348
|
+
expect(json.title).toBe('Internal Server Error');
|
|
349
|
+
expect(json.detail).not.toContain('secret123456');
|
|
350
|
+
errorSpy.mockRestore();
|
|
351
|
+
});
|
|
352
|
+
it('attaches _yaxaConfig metadata for runtime reflection and OpenAPI discovery', () => {
|
|
353
|
+
const config = {
|
|
354
|
+
summary: 'Create Project',
|
|
355
|
+
description: 'Creates a new user project',
|
|
356
|
+
tags: ['Projects'],
|
|
357
|
+
handle: () => ({})
|
|
358
|
+
};
|
|
359
|
+
const endpoint = yaxaEndpoint(config);
|
|
360
|
+
expect(endpoint._yaxaConfig).toBeDefined();
|
|
361
|
+
expect(endpoint._yaxaConfig.summary).toBe('Create Project');
|
|
362
|
+
expect(endpoint._yaxaConfig.tags).toEqual(['Projects']);
|
|
363
|
+
});
|
|
364
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
import type { Guard } from './types';
|
|
3
|
+
export interface RequireAuthOptions {
|
|
4
|
+
/**
|
|
5
|
+
* If provided, unauthorized users are redirected with 303 instead of receiving 401 JSON.
|
|
6
|
+
* (Useful for dashboard/form routes).
|
|
7
|
+
*/
|
|
8
|
+
redirect?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Custom error message for the RFC 7807 detail.
|
|
11
|
+
*/
|
|
12
|
+
detail?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface YaxaGuardMetadata {
|
|
15
|
+
type: 'auth' | 'role' | 'rateLimit' | string;
|
|
16
|
+
roles?: string[];
|
|
17
|
+
options?: any;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Declarative Guard: Enforces that the request has an active authenticated user in `event.locals.user`.
|
|
21
|
+
*/
|
|
22
|
+
export declare function requireAuth(options?: RequireAuthOptions): Guard;
|
|
23
|
+
export interface RequireRoleOptions {
|
|
24
|
+
detail?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Declarative Guard: Enforces that the active user/member possesses one of the allowed roles.
|
|
28
|
+
* Checks `event.locals.user.role`, `event.locals.member.role`, and `event.locals.orgRole`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function requireRole(roles: string | string[], options?: RequireRoleOptions): Guard;
|
|
31
|
+
export interface RateLimitOptions {
|
|
32
|
+
/**
|
|
33
|
+
* Maximum allowed requests within the time window. Default: 60.
|
|
34
|
+
*/
|
|
35
|
+
points?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Window duration in seconds. Default: 60.
|
|
38
|
+
*/
|
|
39
|
+
windowSeconds?: number;
|
|
40
|
+
/**
|
|
41
|
+
* Custom cache key generator. Defaults to IP address or authenticated user ID.
|
|
42
|
+
*/
|
|
43
|
+
keyGenerator?: (event: RequestEvent) => string;
|
|
44
|
+
/**
|
|
45
|
+
* Custom error message.
|
|
46
|
+
*/
|
|
47
|
+
detail?: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Declarative Guard: In-memory sliding-window rate limiter.
|
|
51
|
+
* Returns 429 Too Many Requests with a `Retry-After` header when exceeded.
|
|
52
|
+
*/
|
|
53
|
+
export declare function rateLimit(options?: RateLimitOptions): Guard;
|
|
54
|
+
/**
|
|
55
|
+
* Combines multiple guards into a single composite guard.
|
|
56
|
+
*/
|
|
57
|
+
export declare function composeGuards(...guards: Guard[]): Guard;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { redirect } from '@sveltejs/kit';
|
|
2
|
+
import { problemResponse } from './problem';
|
|
3
|
+
/**
|
|
4
|
+
* Declarative Guard: Enforces that the request has an active authenticated user in `event.locals.user`.
|
|
5
|
+
*/
|
|
6
|
+
export function requireAuth(options = {}) {
|
|
7
|
+
const guard = (event) => {
|
|
8
|
+
const user = event.locals?.user;
|
|
9
|
+
if (!user) {
|
|
10
|
+
if (options.redirect) {
|
|
11
|
+
const returnTo = encodeURIComponent(event.url.pathname + event.url.search);
|
|
12
|
+
const target = options.redirect.includes('?')
|
|
13
|
+
? `${options.redirect}&returnTo=${returnTo}`
|
|
14
|
+
: `${options.redirect}?returnTo=${returnTo}`;
|
|
15
|
+
throw redirect(303, target);
|
|
16
|
+
}
|
|
17
|
+
return problemResponse({
|
|
18
|
+
status: 401,
|
|
19
|
+
title: 'Unauthorized',
|
|
20
|
+
detail: options.detail || 'Authentication required to access this endpoint.',
|
|
21
|
+
type: 'https://yaxa.dev/errors/unauthorized'
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
guard._yaxaGuard = {
|
|
26
|
+
type: 'auth',
|
|
27
|
+
options
|
|
28
|
+
};
|
|
29
|
+
return guard;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Declarative Guard: Enforces that the active user/member possesses one of the allowed roles.
|
|
33
|
+
* Checks `event.locals.user.role`, `event.locals.member.role`, and `event.locals.orgRole`.
|
|
34
|
+
*/
|
|
35
|
+
export function requireRole(roles, options = {}) {
|
|
36
|
+
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
|
37
|
+
const guard = (event) => {
|
|
38
|
+
const locals = event.locals;
|
|
39
|
+
const user = locals?.user;
|
|
40
|
+
if (!user) {
|
|
41
|
+
return problemResponse({
|
|
42
|
+
status: 401,
|
|
43
|
+
title: 'Unauthorized',
|
|
44
|
+
detail: 'Authentication required before role verification.'
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const userRole = user.role;
|
|
48
|
+
const memberRole = locals.member?.role || locals.orgRole;
|
|
49
|
+
const hasRole = (userRole && allowedRoles.includes(userRole)) ||
|
|
50
|
+
(memberRole && allowedRoles.includes(memberRole));
|
|
51
|
+
if (!hasRole) {
|
|
52
|
+
return problemResponse({
|
|
53
|
+
status: 403,
|
|
54
|
+
title: 'Forbidden',
|
|
55
|
+
detail: options.detail ||
|
|
56
|
+
`Insufficient permissions. Requires one of: [${allowedRoles.join(', ')}].`,
|
|
57
|
+
type: 'https://yaxa.dev/errors/forbidden',
|
|
58
|
+
requiredRoles: allowedRoles
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
guard._yaxaGuard = {
|
|
63
|
+
type: 'role',
|
|
64
|
+
roles: allowedRoles,
|
|
65
|
+
options
|
|
66
|
+
};
|
|
67
|
+
return guard;
|
|
68
|
+
}
|
|
69
|
+
const rateLimitStore = new Map();
|
|
70
|
+
// Periodic cleanup of stale rate-limit buckets (every 5 minutes)
|
|
71
|
+
if (typeof setInterval !== 'undefined') {
|
|
72
|
+
const timer = setInterval(() => {
|
|
73
|
+
const now = Date.now();
|
|
74
|
+
for (const [key, bucket] of rateLimitStore.entries()) {
|
|
75
|
+
if (bucket.resetAt <= now) {
|
|
76
|
+
rateLimitStore.delete(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}, 5 * 60 * 1000);
|
|
80
|
+
if (timer && typeof timer === 'object' && 'unref' in timer) {
|
|
81
|
+
timer.unref();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Declarative Guard: In-memory sliding-window rate limiter.
|
|
86
|
+
* Returns 429 Too Many Requests with a `Retry-After` header when exceeded.
|
|
87
|
+
*/
|
|
88
|
+
export function rateLimit(options = {}) {
|
|
89
|
+
const points = options.points ?? 60;
|
|
90
|
+
const windowMs = (options.windowSeconds ?? 60) * 1000;
|
|
91
|
+
const guard = (event) => {
|
|
92
|
+
let key;
|
|
93
|
+
if (options.keyGenerator) {
|
|
94
|
+
key = options.keyGenerator(event);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
const userId = event.locals?.user?.id;
|
|
98
|
+
let clientIp = '127.0.0.1';
|
|
99
|
+
try {
|
|
100
|
+
if (typeof event.getClientAddress === 'function') {
|
|
101
|
+
clientIp = event.getClientAddress();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Fallback when running in environments without getClientAddress
|
|
106
|
+
}
|
|
107
|
+
const headerIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
108
|
+
event.request.headers.get('x-real-ip');
|
|
109
|
+
const ip = headerIp || clientIp;
|
|
110
|
+
key = userId ? `user:${userId}` : `ip:${ip}`;
|
|
111
|
+
}
|
|
112
|
+
const now = Date.now();
|
|
113
|
+
let bucket = rateLimitStore.get(key);
|
|
114
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
115
|
+
bucket = { tokens: points - 1, resetAt: now + windowMs };
|
|
116
|
+
rateLimitStore.set(key, bucket);
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (bucket.tokens <= 0) {
|
|
120
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
|
121
|
+
return problemResponse({
|
|
122
|
+
status: 429,
|
|
123
|
+
title: 'Too Many Requests',
|
|
124
|
+
detail: options.detail || `Rate limit exceeded. Try again in ${retryAfterSeconds} seconds.`,
|
|
125
|
+
type: 'https://yaxa.dev/errors/rate-limit-exceeded',
|
|
126
|
+
headers: {
|
|
127
|
+
'Retry-After': String(retryAfterSeconds),
|
|
128
|
+
'X-RateLimit-Limit': String(points),
|
|
129
|
+
'X-RateLimit-Remaining': '0',
|
|
130
|
+
'X-RateLimit-Reset': String(Math.ceil(bucket.resetAt / 1000))
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
bucket.tokens -= 1;
|
|
135
|
+
};
|
|
136
|
+
guard._yaxaGuard = {
|
|
137
|
+
type: 'rateLimit',
|
|
138
|
+
options: { points, windowSeconds: options.windowSeconds ?? 60 }
|
|
139
|
+
};
|
|
140
|
+
return guard;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Combines multiple guards into a single composite guard.
|
|
144
|
+
*/
|
|
145
|
+
export function composeGuards(...guards) {
|
|
146
|
+
return async (event) => {
|
|
147
|
+
for (const guard of guards) {
|
|
148
|
+
const result = await guard(event);
|
|
149
|
+
if (result)
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|