yaxa-svelte 2.1.0 → 2.2.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 +34 -1
- 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 +52 -0
- package/dist/server/endpoint/guards.js +137 -0
- package/dist/server/endpoint/index.d.ts +14 -0
- package/dist/server/endpoint/index.js +238 -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 +144 -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 +2 -0
- package/dist/server/index.js +2 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -241,13 +241,46 @@ 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
|
+
|
|
244
277
|
## 📦 Subpath Exports Architecture
|
|
245
278
|
|
|
246
279
|
| Subpath | Description |
|
|
247
280
|
| :---------------------- | :---------------------------------------------------------------------------------------------------------------------------------------- |
|
|
248
281
|
| `yaxa-svelte` | Core UI Primitives, SEO Components, `<Gate />`, Theme System & Composables (`useGate`, `useUpload`, `useToast`, `useClipboard`, etc.) |
|
|
249
282
|
| `yaxa-svelte/admin` | Drizzle ORM Schema Introspection, `<AdminDashboard />`, `createYaxaAdminHook`, `createDrizzleAdmin`, `<DevSandbox />`, `<RecordDrawer />` |
|
|
250
|
-
| `yaxa-svelte/server` | Universal Server Hooks, SEO endpoints, Drizzle DB
|
|
283
|
+
| `yaxa-svelte/server` | `yaxaEndpoint` Pipeline, Declarative Guards, RFC 7807, Universal Server Hooks, SEO endpoints, Drizzle DB, Storage & Email |
|
|
251
284
|
| `yaxa-svelte/auth` | Better-Auth client & server integration (`useAuth`, `<AuthCard />`, `<PasskeyUI />`, `<TwoFactorModal />`, `createYaxaAuth`) |
|
|
252
285
|
| `yaxa-svelte/db` | Multi-dialect Drizzle ORM clients & schemas for Neon (PostgreSQL) and Turso (SQLite/LibSQL) |
|
|
253
286
|
| `yaxa-svelte/polar` | Polar.sh billing, customer portal sessions, and webhook synchronization |
|
|
@@ -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,52 @@
|
|
|
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
|
+
/**
|
|
15
|
+
* Declarative Guard: Enforces that the request has an active authenticated user in `event.locals.user`.
|
|
16
|
+
*/
|
|
17
|
+
export declare function requireAuth(options?: RequireAuthOptions): Guard;
|
|
18
|
+
export interface RequireRoleOptions {
|
|
19
|
+
detail?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Declarative Guard: Enforces that the active user/member possesses one of the allowed roles.
|
|
23
|
+
* Checks `event.locals.user.role`, `event.locals.member.role`, and `event.locals.orgRole`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function requireRole(roles: string | string[], options?: RequireRoleOptions): Guard;
|
|
26
|
+
export interface RateLimitOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Maximum allowed requests within the time window. Default: 60.
|
|
29
|
+
*/
|
|
30
|
+
points?: number;
|
|
31
|
+
/**
|
|
32
|
+
* Window duration in seconds. Default: 60.
|
|
33
|
+
*/
|
|
34
|
+
windowSeconds?: number;
|
|
35
|
+
/**
|
|
36
|
+
* Custom cache key generator. Defaults to IP address or authenticated user ID.
|
|
37
|
+
*/
|
|
38
|
+
keyGenerator?: (event: RequestEvent) => string;
|
|
39
|
+
/**
|
|
40
|
+
* Custom error message.
|
|
41
|
+
*/
|
|
42
|
+
detail?: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Declarative Guard: In-memory sliding-window rate limiter.
|
|
46
|
+
* Returns 429 Too Many Requests with a `Retry-After` header when exceeded.
|
|
47
|
+
*/
|
|
48
|
+
export declare function rateLimit(options?: RateLimitOptions): Guard;
|
|
49
|
+
/**
|
|
50
|
+
* Combines multiple guards into a single composite guard.
|
|
51
|
+
*/
|
|
52
|
+
export declare function composeGuards(...guards: Guard[]): Guard;
|
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
return (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
|
+
}
|
|
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 function requireRole(roles, options = {}) {
|
|
31
|
+
const allowedRoles = Array.isArray(roles) ? roles : [roles];
|
|
32
|
+
return (event) => {
|
|
33
|
+
const locals = event.locals;
|
|
34
|
+
const user = locals?.user;
|
|
35
|
+
if (!user) {
|
|
36
|
+
return problemResponse({
|
|
37
|
+
status: 401,
|
|
38
|
+
title: 'Unauthorized',
|
|
39
|
+
detail: 'Authentication required before role verification.'
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const userRole = user.role;
|
|
43
|
+
const memberRole = locals.member?.role || locals.orgRole;
|
|
44
|
+
const hasRole = (userRole && allowedRoles.includes(userRole)) ||
|
|
45
|
+
(memberRole && allowedRoles.includes(memberRole));
|
|
46
|
+
if (!hasRole) {
|
|
47
|
+
return problemResponse({
|
|
48
|
+
status: 403,
|
|
49
|
+
title: 'Forbidden',
|
|
50
|
+
detail: options.detail ||
|
|
51
|
+
`Insufficient permissions. Requires one of: [${allowedRoles.join(', ')}].`,
|
|
52
|
+
type: 'https://yaxa.dev/errors/forbidden',
|
|
53
|
+
requiredRoles: allowedRoles
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const rateLimitStore = new Map();
|
|
59
|
+
// Periodic cleanup of stale rate-limit buckets (every 5 minutes)
|
|
60
|
+
if (typeof setInterval !== 'undefined') {
|
|
61
|
+
const timer = setInterval(() => {
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
for (const [key, bucket] of rateLimitStore.entries()) {
|
|
64
|
+
if (bucket.resetAt <= now) {
|
|
65
|
+
rateLimitStore.delete(key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}, 5 * 60 * 1000);
|
|
69
|
+
if (timer && typeof timer === 'object' && 'unref' in timer) {
|
|
70
|
+
timer.unref();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Declarative Guard: In-memory sliding-window rate limiter.
|
|
75
|
+
* Returns 429 Too Many Requests with a `Retry-After` header when exceeded.
|
|
76
|
+
*/
|
|
77
|
+
export function rateLimit(options = {}) {
|
|
78
|
+
const points = options.points ?? 60;
|
|
79
|
+
const windowMs = (options.windowSeconds ?? 60) * 1000;
|
|
80
|
+
return (event) => {
|
|
81
|
+
let key;
|
|
82
|
+
if (options.keyGenerator) {
|
|
83
|
+
key = options.keyGenerator(event);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
const userId = event.locals?.user?.id;
|
|
87
|
+
let clientIp = '127.0.0.1';
|
|
88
|
+
try {
|
|
89
|
+
if (typeof event.getClientAddress === 'function') {
|
|
90
|
+
clientIp = event.getClientAddress();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Fallback when running in environments without getClientAddress
|
|
95
|
+
}
|
|
96
|
+
const headerIp = event.request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
|
|
97
|
+
event.request.headers.get('x-real-ip');
|
|
98
|
+
const ip = headerIp || clientIp;
|
|
99
|
+
key = userId ? `user:${userId}` : `ip:${ip}`;
|
|
100
|
+
}
|
|
101
|
+
const now = Date.now();
|
|
102
|
+
let bucket = rateLimitStore.get(key);
|
|
103
|
+
if (!bucket || bucket.resetAt <= now) {
|
|
104
|
+
bucket = { tokens: points - 1, resetAt: now + windowMs };
|
|
105
|
+
rateLimitStore.set(key, bucket);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (bucket.tokens <= 0) {
|
|
109
|
+
const retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000));
|
|
110
|
+
return problemResponse({
|
|
111
|
+
status: 429,
|
|
112
|
+
title: 'Too Many Requests',
|
|
113
|
+
detail: options.detail || `Rate limit exceeded. Try again in ${retryAfterSeconds} seconds.`,
|
|
114
|
+
type: 'https://yaxa.dev/errors/rate-limit-exceeded',
|
|
115
|
+
headers: {
|
|
116
|
+
'Retry-After': String(retryAfterSeconds),
|
|
117
|
+
'X-RateLimit-Limit': String(points),
|
|
118
|
+
'X-RateLimit-Remaining': '0',
|
|
119
|
+
'X-RateLimit-Reset': String(Math.ceil(bucket.resetAt / 1000))
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
bucket.tokens -= 1;
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Combines multiple guards into a single composite guard.
|
|
128
|
+
*/
|
|
129
|
+
export function composeGuards(...guards) {
|
|
130
|
+
return async (event) => {
|
|
131
|
+
for (const guard of guards) {
|
|
132
|
+
const result = await guard(event);
|
|
133
|
+
if (result)
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RequestHandler } from '@sveltejs/kit';
|
|
2
|
+
import type { YaxaEndpointConfig } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Wraps SvelteKit +server.ts request handlers into an opinionated,
|
|
5
|
+
* declarative, type-safe execution pipeline with Guards, Multi-Engine
|
|
6
|
+
* Validation, and RFC 7807 Problem Details.
|
|
7
|
+
*/
|
|
8
|
+
export declare function yaxaEndpoint<TBodySchema = any, TQuerySchema = any, TParamsSchema = any, THeadersSchema = any, TResult = any>(config: YaxaEndpointConfig<TBodySchema, TQuerySchema, TParamsSchema, THeadersSchema, TResult>): RequestHandler & {
|
|
9
|
+
_yaxaConfig: typeof config;
|
|
10
|
+
};
|
|
11
|
+
export * from './types';
|
|
12
|
+
export * from './problem';
|
|
13
|
+
export * from './guards';
|
|
14
|
+
export * from './validate';
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { isProblemDetails, problemResponse } from './problem';
|
|
2
|
+
import { validateData } from './validate';
|
|
3
|
+
/**
|
|
4
|
+
* Parses URLSearchParams into a plain key-value object.
|
|
5
|
+
* Duplicate keys are parsed into arrays.
|
|
6
|
+
*/
|
|
7
|
+
function parseSearchParamsToObject(searchParams) {
|
|
8
|
+
const result = {};
|
|
9
|
+
for (const key of searchParams.keys()) {
|
|
10
|
+
const allValues = searchParams.getAll(key);
|
|
11
|
+
if (allValues.length > 1) {
|
|
12
|
+
result[key] = allValues;
|
|
13
|
+
}
|
|
14
|
+
else if (allValues.length === 1) {
|
|
15
|
+
result[key] = allValues[0];
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return result;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Safely parses request body based on Content-Type.
|
|
22
|
+
*/
|
|
23
|
+
async function parseRequestBody(request) {
|
|
24
|
+
const contentType = request.headers.get('content-type') || '';
|
|
25
|
+
if (contentType.includes('application/json')) {
|
|
26
|
+
try {
|
|
27
|
+
const text = await request.text();
|
|
28
|
+
if (!text.trim()) {
|
|
29
|
+
return { ok: true, body: undefined };
|
|
30
|
+
}
|
|
31
|
+
return { ok: true, body: JSON.parse(text) };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
response: problemResponse({
|
|
37
|
+
status: 400,
|
|
38
|
+
title: 'Bad Request',
|
|
39
|
+
detail: 'Malformed JSON payload in request body.',
|
|
40
|
+
type: 'https://yaxa.dev/errors/malformed-json'
|
|
41
|
+
})
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
if (contentType.includes('application/x-www-form-urlencoded') ||
|
|
46
|
+
contentType.includes('multipart/form-data')) {
|
|
47
|
+
try {
|
|
48
|
+
const formData = await request.formData();
|
|
49
|
+
const formObj = {};
|
|
50
|
+
formData.forEach((val, key) => {
|
|
51
|
+
if (key in formObj) {
|
|
52
|
+
if (Array.isArray(formObj[key])) {
|
|
53
|
+
formObj[key].push(val);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
formObj[key] = [formObj[key], val];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
formObj[key] = val;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
return { ok: true, body: formObj };
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
response: problemResponse({
|
|
69
|
+
status: 400,
|
|
70
|
+
title: 'Bad Request',
|
|
71
|
+
detail: 'Malformed form-data in request body.'
|
|
72
|
+
})
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Plain text or other payloads
|
|
77
|
+
try {
|
|
78
|
+
const text = await request.text();
|
|
79
|
+
return { ok: true, body: text ? text : undefined };
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return { ok: true, body: undefined };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Wraps SvelteKit +server.ts request handlers into an opinionated,
|
|
87
|
+
* declarative, type-safe execution pipeline with Guards, Multi-Engine
|
|
88
|
+
* Validation, and RFC 7807 Problem Details.
|
|
89
|
+
*/
|
|
90
|
+
export function yaxaEndpoint(config) {
|
|
91
|
+
const handler = async (event) => {
|
|
92
|
+
const { request, url, locals, params } = event;
|
|
93
|
+
// 1. Execute Declarative Guards in Series
|
|
94
|
+
if (config.guards && config.guards.length > 0) {
|
|
95
|
+
for (const guard of config.guards) {
|
|
96
|
+
const result = await guard(event);
|
|
97
|
+
if (result instanceof Response) {
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
if (isProblemDetails(result)) {
|
|
101
|
+
return problemResponse(result);
|
|
102
|
+
}
|
|
103
|
+
if (result === false) {
|
|
104
|
+
return problemResponse({
|
|
105
|
+
status: 403,
|
|
106
|
+
title: 'Forbidden',
|
|
107
|
+
detail: 'Access denied by endpoint guard.'
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// 2. Parse Raw Inputs
|
|
113
|
+
const rawParams = params || {};
|
|
114
|
+
const rawQuery = parseSearchParamsToObject(url.searchParams);
|
|
115
|
+
const rawHeaders = {};
|
|
116
|
+
request.headers.forEach((val, key) => {
|
|
117
|
+
rawHeaders[key.toLowerCase()] = val;
|
|
118
|
+
});
|
|
119
|
+
let rawBody;
|
|
120
|
+
if (request.method !== 'GET' && request.method !== 'HEAD') {
|
|
121
|
+
const parsed = await parseRequestBody(request);
|
|
122
|
+
if (!parsed.ok) {
|
|
123
|
+
return parsed.response;
|
|
124
|
+
}
|
|
125
|
+
rawBody = parsed.body;
|
|
126
|
+
}
|
|
127
|
+
// 3. Multi-Engine Schema Validation (Standard Schema, Zod, etc.)
|
|
128
|
+
const allInvalidParams = [];
|
|
129
|
+
let validatedBody = rawBody;
|
|
130
|
+
let validatedQuery = rawQuery;
|
|
131
|
+
let validatedParams = rawParams;
|
|
132
|
+
let validatedHeaders = rawHeaders;
|
|
133
|
+
if (config.schema) {
|
|
134
|
+
if (config.schema.params) {
|
|
135
|
+
const res = await validateData(config.schema.params, rawParams, 'params');
|
|
136
|
+
if (res.success)
|
|
137
|
+
validatedParams = res.data;
|
|
138
|
+
else
|
|
139
|
+
allInvalidParams.push(...res.invalidParams);
|
|
140
|
+
}
|
|
141
|
+
if (config.schema.query) {
|
|
142
|
+
const res = await validateData(config.schema.query, rawQuery, 'query');
|
|
143
|
+
if (res.success)
|
|
144
|
+
validatedQuery = res.data;
|
|
145
|
+
else
|
|
146
|
+
allInvalidParams.push(...res.invalidParams);
|
|
147
|
+
}
|
|
148
|
+
if (config.schema.headers) {
|
|
149
|
+
const res = await validateData(config.schema.headers, rawHeaders, 'headers');
|
|
150
|
+
if (res.success)
|
|
151
|
+
validatedHeaders = res.data;
|
|
152
|
+
else
|
|
153
|
+
allInvalidParams.push(...res.invalidParams);
|
|
154
|
+
}
|
|
155
|
+
if (config.schema.body && request.method !== 'GET' && request.method !== 'HEAD') {
|
|
156
|
+
const res = await validateData(config.schema.body, rawBody, 'body');
|
|
157
|
+
if (res.success)
|
|
158
|
+
validatedBody = res.data;
|
|
159
|
+
else
|
|
160
|
+
allInvalidParams.push(...res.invalidParams);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (allInvalidParams.length > 0) {
|
|
164
|
+
return problemResponse({
|
|
165
|
+
status: 422,
|
|
166
|
+
title: 'Unprocessable Entity',
|
|
167
|
+
detail: `Validation failed for ${allInvalidParams.length} parameter(s).`,
|
|
168
|
+
type: 'https://yaxa.dev/errors/validation-failed',
|
|
169
|
+
invalidParams: allInvalidParams
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
// 4. Construct Rich Endpoint Context
|
|
173
|
+
const context = {
|
|
174
|
+
event,
|
|
175
|
+
request,
|
|
176
|
+
url,
|
|
177
|
+
locals,
|
|
178
|
+
body: validatedBody,
|
|
179
|
+
query: validatedQuery,
|
|
180
|
+
params: validatedParams,
|
|
181
|
+
headers: validatedHeaders,
|
|
182
|
+
user: locals?.user || null,
|
|
183
|
+
session: locals?.session || null,
|
|
184
|
+
orgId: locals?.orgId || null
|
|
185
|
+
};
|
|
186
|
+
// 5. Execute Handler wrapped in Interceptors & Error Boundary
|
|
187
|
+
const runHandler = async () => {
|
|
188
|
+
const defaultStatus = config.status || 200;
|
|
189
|
+
let result;
|
|
190
|
+
try {
|
|
191
|
+
result = await config.handle(context);
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
// Allow SvelteKit redirects and HTTP errors to bubble up
|
|
195
|
+
if (err && typeof err === 'object' && ('status' in err || 'location' in err)) {
|
|
196
|
+
throw err;
|
|
197
|
+
}
|
|
198
|
+
if (err instanceof Response) {
|
|
199
|
+
return err;
|
|
200
|
+
}
|
|
201
|
+
console.error('[Yaxa Endpoint Error]', err);
|
|
202
|
+
return problemResponse({
|
|
203
|
+
status: 500,
|
|
204
|
+
title: 'Internal Server Error',
|
|
205
|
+
detail: 'An unexpected internal error occurred.',
|
|
206
|
+
type: 'https://yaxa.dev/errors/internal-server-error'
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
// 6. Response Auto-Serialization
|
|
210
|
+
if (result instanceof Response) {
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
if (result === undefined || result === null) {
|
|
214
|
+
return new Response(null, { status: 204 });
|
|
215
|
+
}
|
|
216
|
+
if (typeof result === 'string') {
|
|
217
|
+
return new Response(result, {
|
|
218
|
+
status: defaultStatus,
|
|
219
|
+
headers: { 'content-type': 'text/plain; charset=utf-8' }
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return Response.json(result, { status: defaultStatus });
|
|
223
|
+
};
|
|
224
|
+
// 7. Apply Interceptors (if any)
|
|
225
|
+
if (config.interceptors && config.interceptors.length > 0) {
|
|
226
|
+
const pipeline = config.interceptors.reduceRight((next, interceptor) => () => interceptor(context, next), runHandler);
|
|
227
|
+
return pipeline();
|
|
228
|
+
}
|
|
229
|
+
return runHandler();
|
|
230
|
+
};
|
|
231
|
+
// Attach configuration metadata for runtime reflection and OpenAPI generation (Milestone 3)
|
|
232
|
+
handler._yaxaConfig = config;
|
|
233
|
+
return handler;
|
|
234
|
+
}
|
|
235
|
+
export * from './types';
|
|
236
|
+
export * from './problem';
|
|
237
|
+
export * from './guards';
|
|
238
|
+
export * from './validate';
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { ProblemDetails } from './types';
|
|
2
|
+
export interface CreateProblemOptions {
|
|
3
|
+
status: number;
|
|
4
|
+
title?: string;
|
|
5
|
+
detail?: string;
|
|
6
|
+
type?: string;
|
|
7
|
+
instance?: string;
|
|
8
|
+
invalidParams?: Array<{
|
|
9
|
+
name: string;
|
|
10
|
+
reason: string;
|
|
11
|
+
}>;
|
|
12
|
+
headers?: HeadersInit;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Creates an RFC 7807 Problem Details object.
|
|
17
|
+
*/
|
|
18
|
+
export declare function createProblemDetails(options: CreateProblemOptions): ProblemDetails;
|
|
19
|
+
/**
|
|
20
|
+
* Creates a standard SvelteKit Response formatted with application/problem+json.
|
|
21
|
+
*/
|
|
22
|
+
export declare function problemResponse(options: CreateProblemOptions): Response;
|
|
23
|
+
/**
|
|
24
|
+
* Type-guard checking if an object conforms to ProblemDetails.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isProblemDetails(value: unknown): value is ProblemDetails;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Standard HTTP error status code to human-readable RFC 7807 titles.
|
|
3
|
+
*/
|
|
4
|
+
const STATUS_TITLES = {
|
|
5
|
+
400: 'Bad Request',
|
|
6
|
+
401: 'Unauthorized',
|
|
7
|
+
403: 'Forbidden',
|
|
8
|
+
404: 'Not Found',
|
|
9
|
+
405: 'Method Not Allowed',
|
|
10
|
+
409: 'Conflict',
|
|
11
|
+
422: 'Unprocessable Entity',
|
|
12
|
+
429: 'Too Many Requests',
|
|
13
|
+
500: 'Internal Server Error',
|
|
14
|
+
502: 'Bad Gateway',
|
|
15
|
+
503: 'Service Unavailable',
|
|
16
|
+
504: 'Gateway Timeout'
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Creates an RFC 7807 Problem Details object.
|
|
20
|
+
*/
|
|
21
|
+
export function createProblemDetails(options) {
|
|
22
|
+
const status = options.status || 500;
|
|
23
|
+
const title = options.title || STATUS_TITLES[status] || 'Internal Error';
|
|
24
|
+
const type = options.type || `https://httpstatuses.com/${status}`;
|
|
25
|
+
const problem = {
|
|
26
|
+
type,
|
|
27
|
+
title,
|
|
28
|
+
status
|
|
29
|
+
};
|
|
30
|
+
for (const [key, value] of Object.entries(options)) {
|
|
31
|
+
if (key !== 'headers' && key !== 'status' && key !== 'title' && key !== 'type') {
|
|
32
|
+
problem[key] = value;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return problem;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Creates a standard SvelteKit Response formatted with application/problem+json.
|
|
39
|
+
*/
|
|
40
|
+
export function problemResponse(options) {
|
|
41
|
+
const status = options.status || 500;
|
|
42
|
+
const problem = createProblemDetails(options);
|
|
43
|
+
const customHeaders = new Headers(options.headers || {});
|
|
44
|
+
if (!customHeaders.has('content-type')) {
|
|
45
|
+
customHeaders.set('content-type', 'application/problem+json; charset=utf-8');
|
|
46
|
+
}
|
|
47
|
+
return new Response(JSON.stringify(problem), {
|
|
48
|
+
status,
|
|
49
|
+
headers: customHeaders
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Type-guard checking if an object conforms to ProblemDetails.
|
|
54
|
+
*/
|
|
55
|
+
export function isProblemDetails(value) {
|
|
56
|
+
return (typeof value === 'object' &&
|
|
57
|
+
value !== null &&
|
|
58
|
+
'status' in value &&
|
|
59
|
+
typeof value.status === 'number' &&
|
|
60
|
+
'title' in value &&
|
|
61
|
+
'type' in value);
|
|
62
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
2
|
+
/**
|
|
3
|
+
* Standard RFC 7807 Problem Details representation.
|
|
4
|
+
* @see https://datatracker.ietf.org/doc/html/rfc7807
|
|
5
|
+
*/
|
|
6
|
+
export interface ProblemDetails {
|
|
7
|
+
type: string;
|
|
8
|
+
title: string;
|
|
9
|
+
status: number;
|
|
10
|
+
detail?: string;
|
|
11
|
+
instance?: string;
|
|
12
|
+
invalidParams?: Array<{
|
|
13
|
+
name: string;
|
|
14
|
+
reason: string;
|
|
15
|
+
}>;
|
|
16
|
+
[key: string]: unknown;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Generic guard return value.
|
|
20
|
+
* - Return `Response` or `ProblemDetails`: Immediately stops pipeline and sends HTTP response.
|
|
21
|
+
* - Return `void` or `undefined` or `true`: Guard passed; continues to next step.
|
|
22
|
+
*/
|
|
23
|
+
export type GuardResult = Response | ProblemDetails | void | undefined | boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Guard function signature.
|
|
26
|
+
*/
|
|
27
|
+
export type Guard = (event: RequestEvent) => Promise<GuardResult> | GuardResult;
|
|
28
|
+
/**
|
|
29
|
+
* Standard Schema or validator representation.
|
|
30
|
+
* Supports:
|
|
31
|
+
* 1. Standard Schema v1 (`~standard` property, e.g. Zod v3.24+, Zod v4, Valibot, ArkType)
|
|
32
|
+
* 2. `.safeParse()` (Zod, Yup, etc.)
|
|
33
|
+
* 3. Functional validator: `(data: unknown) => { success: boolean; data?: T; error?: any }`
|
|
34
|
+
*/
|
|
35
|
+
export type AnyValidator<T = any> = {
|
|
36
|
+
'~standard': {
|
|
37
|
+
validate: (value: unknown) => Promise<{
|
|
38
|
+
value?: T;
|
|
39
|
+
issues?: ReadonlyArray<any>;
|
|
40
|
+
}> | {
|
|
41
|
+
value?: T;
|
|
42
|
+
issues?: ReadonlyArray<any>;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
} | {
|
|
46
|
+
safeParse: (value: unknown) => {
|
|
47
|
+
success: true;
|
|
48
|
+
data: T;
|
|
49
|
+
} | {
|
|
50
|
+
success: false;
|
|
51
|
+
error: any;
|
|
52
|
+
};
|
|
53
|
+
} | {
|
|
54
|
+
parse: (value: unknown) => T;
|
|
55
|
+
} | ((value: unknown) => {
|
|
56
|
+
success: boolean;
|
|
57
|
+
data?: T;
|
|
58
|
+
error?: any;
|
|
59
|
+
});
|
|
60
|
+
/**
|
|
61
|
+
* Infer output type from a validator.
|
|
62
|
+
*/
|
|
63
|
+
export type InferValidatorOutput<V> = V extends {
|
|
64
|
+
'~standard': {
|
|
65
|
+
validate: (...args: any[]) => Promise<{
|
|
66
|
+
value?: infer T;
|
|
67
|
+
}> | {
|
|
68
|
+
value?: infer T;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
} ? T : V extends {
|
|
72
|
+
safeParse: (...args: any[]) => {
|
|
73
|
+
success: true;
|
|
74
|
+
data: infer T;
|
|
75
|
+
} | {
|
|
76
|
+
success: false;
|
|
77
|
+
error: any;
|
|
78
|
+
};
|
|
79
|
+
} ? T : V extends {
|
|
80
|
+
parse: (...args: any[]) => infer T;
|
|
81
|
+
} ? T : V extends (value: unknown) => {
|
|
82
|
+
success: true;
|
|
83
|
+
data: infer T;
|
|
84
|
+
} ? T : any;
|
|
85
|
+
export interface YaxaEndpointSchemaConfig<TBody = any, TQuery = any, TParams = any, THeaders = any> {
|
|
86
|
+
body?: TBody;
|
|
87
|
+
query?: TQuery;
|
|
88
|
+
params?: TParams;
|
|
89
|
+
headers?: THeaders;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Rich context passed to business handler in yaxaEndpoint.
|
|
93
|
+
*/
|
|
94
|
+
export interface YaxaEndpointContext<TBody = any, TQuery = any, TParams = any, THeaders = any> {
|
|
95
|
+
event: RequestEvent;
|
|
96
|
+
request: Request;
|
|
97
|
+
url: URL;
|
|
98
|
+
locals: App.Locals;
|
|
99
|
+
body: TBody;
|
|
100
|
+
query: TQuery;
|
|
101
|
+
params: TParams;
|
|
102
|
+
headers: THeaders;
|
|
103
|
+
user: any;
|
|
104
|
+
session: any;
|
|
105
|
+
orgId: string | null;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Interceptor function wrapping handler execution.
|
|
109
|
+
*/
|
|
110
|
+
export type YaxaInterceptor = (context: YaxaEndpointContext, next: () => Promise<Response>) => Promise<Response>;
|
|
111
|
+
/**
|
|
112
|
+
* Endpoint configuration passed to yaxaEndpoint().
|
|
113
|
+
*/
|
|
114
|
+
export interface YaxaEndpointConfig<TBodySchema = any, TQuerySchema = any, TParamsSchema = any, THeadersSchema = any, TResult = any> {
|
|
115
|
+
/**
|
|
116
|
+
* Sequence of declarative guards executed before handler body.
|
|
117
|
+
* (e.g. requireAuth(), requireRole(['admin']), rateLimit({ points: 10 }))
|
|
118
|
+
*/
|
|
119
|
+
guards?: Guard[];
|
|
120
|
+
/**
|
|
121
|
+
* Schemas for input payload validation (body, query string, path params, headers).
|
|
122
|
+
*/
|
|
123
|
+
schema?: YaxaEndpointSchemaConfig<TBodySchema, TQuerySchema, TParamsSchema, THeadersSchema>;
|
|
124
|
+
/**
|
|
125
|
+
* Optional status code to return for successful non-Response return values.
|
|
126
|
+
* Defaults to 200 (or 201 for POST when returning created object if desired).
|
|
127
|
+
*/
|
|
128
|
+
status?: number;
|
|
129
|
+
/**
|
|
130
|
+
* Optional response interceptors (e.g. audit logging, timing headers).
|
|
131
|
+
*/
|
|
132
|
+
interceptors?: YaxaInterceptor[];
|
|
133
|
+
/**
|
|
134
|
+
* OpenAPI metadata annotations (used by Milestone 3 auto-docs).
|
|
135
|
+
*/
|
|
136
|
+
summary?: string;
|
|
137
|
+
description?: string;
|
|
138
|
+
tags?: string[];
|
|
139
|
+
deprecated?: boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Core business logic handler.
|
|
142
|
+
*/
|
|
143
|
+
handle: (context: YaxaEndpointContext<InferValidatorOutput<TBodySchema>, InferValidatorOutput<TQuerySchema>, InferValidatorOutput<TParamsSchema>, InferValidatorOutput<THeadersSchema>>) => Promise<TResult> | TResult;
|
|
144
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ValidationSuccess<T> {
|
|
2
|
+
success: true;
|
|
3
|
+
data: T;
|
|
4
|
+
}
|
|
5
|
+
export interface ValidationFailure {
|
|
6
|
+
success: false;
|
|
7
|
+
invalidParams: Array<{
|
|
8
|
+
name: string;
|
|
9
|
+
reason: string;
|
|
10
|
+
}>;
|
|
11
|
+
rawError?: any;
|
|
12
|
+
}
|
|
13
|
+
export type ValidationOutcome<T> = ValidationSuccess<T> | ValidationFailure;
|
|
14
|
+
/**
|
|
15
|
+
* Validates arbitrary input data against any supported validator:
|
|
16
|
+
* 1. Standard Schema v1 (`~standard` property, e.g. Zod v3.24+, Zod v4, Valibot, ArkType)
|
|
17
|
+
* 2. `.safeParse()` (Zod, Yup, etc.)
|
|
18
|
+
* 3. `.parse()`
|
|
19
|
+
* 4. Custom validation function: `(data) => { success, data, error }`
|
|
20
|
+
*/
|
|
21
|
+
export declare function validateData<T = any>(validator: any, input: unknown, scopePrefix?: string): Promise<ValidationOutcome<T>>;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates arbitrary input data against any supported validator:
|
|
3
|
+
* 1. Standard Schema v1 (`~standard` property, e.g. Zod v3.24+, Zod v4, Valibot, ArkType)
|
|
4
|
+
* 2. `.safeParse()` (Zod, Yup, etc.)
|
|
5
|
+
* 3. `.parse()`
|
|
6
|
+
* 4. Custom validation function: `(data) => { success, data, error }`
|
|
7
|
+
*/
|
|
8
|
+
export async function validateData(validator, input, scopePrefix = '') {
|
|
9
|
+
if (!validator) {
|
|
10
|
+
return { success: true, data: input };
|
|
11
|
+
}
|
|
12
|
+
// 1. Standard Schema v1 (~standard specification)
|
|
13
|
+
if (typeof validator === 'object' && validator !== null && '~standard' in validator) {
|
|
14
|
+
try {
|
|
15
|
+
const std = validator['~standard'];
|
|
16
|
+
const result = await std.validate(input);
|
|
17
|
+
if (result &&
|
|
18
|
+
'issues' in result &&
|
|
19
|
+
Array.isArray(result.issues) &&
|
|
20
|
+
result.issues.length > 0) {
|
|
21
|
+
const invalidParams = result.issues.map((issue) => {
|
|
22
|
+
let pathStr = '';
|
|
23
|
+
if (Array.isArray(issue.path)) {
|
|
24
|
+
pathStr = issue.path
|
|
25
|
+
.map((seg) => typeof seg === 'object' && seg !== null && 'key' in seg ? seg.key : String(seg))
|
|
26
|
+
.join('.');
|
|
27
|
+
}
|
|
28
|
+
const name = scopePrefix
|
|
29
|
+
? pathStr
|
|
30
|
+
? `${scopePrefix}.${pathStr}`
|
|
31
|
+
: scopePrefix
|
|
32
|
+
: pathStr || 'value';
|
|
33
|
+
return {
|
|
34
|
+
name,
|
|
35
|
+
reason: issue.message || 'Invalid value'
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
return { success: false, invalidParams, rawError: result.issues };
|
|
39
|
+
}
|
|
40
|
+
return { success: true, data: result.value };
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
return {
|
|
44
|
+
success: false,
|
|
45
|
+
invalidParams: [
|
|
46
|
+
{ name: scopePrefix || 'value', reason: err?.message || 'Validation failed' }
|
|
47
|
+
],
|
|
48
|
+
rawError: err
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// 2. Standard .safeParse() (Zod, etc.)
|
|
53
|
+
if (typeof validator === 'object' &&
|
|
54
|
+
validator !== null &&
|
|
55
|
+
typeof validator.safeParse === 'function') {
|
|
56
|
+
const parsed = validator.safeParse(input);
|
|
57
|
+
if (parsed.success) {
|
|
58
|
+
return { success: true, data: parsed.data };
|
|
59
|
+
}
|
|
60
|
+
// ZodError or similar
|
|
61
|
+
const issues = parsed.error?.issues || parsed.error?.errors || [];
|
|
62
|
+
const invalidParams = issues.length > 0
|
|
63
|
+
? issues.map((issue) => {
|
|
64
|
+
const pathStr = Array.isArray(issue.path) ? issue.path.join('.') : '';
|
|
65
|
+
const name = scopePrefix
|
|
66
|
+
? pathStr
|
|
67
|
+
? `${scopePrefix}.${pathStr}`
|
|
68
|
+
: scopePrefix
|
|
69
|
+
: pathStr || 'value';
|
|
70
|
+
return {
|
|
71
|
+
name,
|
|
72
|
+
reason: issue.message || 'Validation failed'
|
|
73
|
+
};
|
|
74
|
+
})
|
|
75
|
+
: [{ name: scopePrefix || 'value', reason: parsed.error?.message || 'Validation failed' }];
|
|
76
|
+
return { success: false, invalidParams, rawError: parsed.error };
|
|
77
|
+
}
|
|
78
|
+
// 3. Functional Validator
|
|
79
|
+
if (typeof validator === 'function') {
|
|
80
|
+
try {
|
|
81
|
+
const res = validator(input);
|
|
82
|
+
if (res && typeof res === 'object' && 'success' in res) {
|
|
83
|
+
if (res.success) {
|
|
84
|
+
return { success: true, data: (res.data !== undefined ? res.data : input) };
|
|
85
|
+
}
|
|
86
|
+
const reason = res.error?.message || (typeof res.error === 'string' ? res.error : 'Validation failed');
|
|
87
|
+
return {
|
|
88
|
+
success: false,
|
|
89
|
+
invalidParams: [{ name: scopePrefix || 'value', reason }],
|
|
90
|
+
rawError: res.error
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
// If function returned parsed data without error
|
|
94
|
+
return { success: true, data: res };
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
return {
|
|
98
|
+
success: false,
|
|
99
|
+
invalidParams: [
|
|
100
|
+
{ name: scopePrefix || 'value', reason: err?.message || 'Validation failed' }
|
|
101
|
+
],
|
|
102
|
+
rawError: err
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
// 4. Standard .parse() throwing validator
|
|
107
|
+
if (typeof validator === 'object' &&
|
|
108
|
+
validator !== null &&
|
|
109
|
+
typeof validator.parse === 'function') {
|
|
110
|
+
try {
|
|
111
|
+
const data = validator.parse(input);
|
|
112
|
+
return { success: true, data: data };
|
|
113
|
+
}
|
|
114
|
+
catch (err) {
|
|
115
|
+
const issues = err?.issues || err?.errors || [];
|
|
116
|
+
const invalidParams = issues.length > 0
|
|
117
|
+
? issues.map((issue) => ({
|
|
118
|
+
name: scopePrefix
|
|
119
|
+
? `${scopePrefix}.${issue.path?.join('.')}`
|
|
120
|
+
: issue.path?.join('.') || 'value',
|
|
121
|
+
reason: issue.message || 'Validation failed'
|
|
122
|
+
}))
|
|
123
|
+
: [{ name: scopePrefix || 'value', reason: err?.message || 'Validation failed' }];
|
|
124
|
+
return { success: false, invalidParams, rawError: err };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { success: true, data: input };
|
|
128
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -22,3 +22,5 @@ export { getResendClient, sendMagicLinkEmail, sendWelcomeEmail } from './email/i
|
|
|
22
22
|
export type { ResendEmailConfig, SendMagicLinkOptions, SendWelcomeEmailOptions } from './email/index';
|
|
23
23
|
export { getS3Client, createPresignedUploadUrl, createS3UploadHandler } from './storage/index';
|
|
24
24
|
export type { S3StorageConfig, PresignedUploadOptions, PresignedUploadResult, S3UploadHandlerOptions } from './storage/index';
|
|
25
|
+
export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails, requireAuth, requireRole, rateLimit, composeGuards, validateData } from './endpoint/index';
|
|
26
|
+
export type { YaxaEndpointConfig, YaxaEndpointContext, YaxaEndpointSchemaConfig, YaxaInterceptor, Guard, GuardResult, ProblemDetails, CreateProblemOptions, RequireAuthOptions, RequireRoleOptions, RateLimitOptions, AnyValidator, InferValidatorOutput, ValidationOutcome, ValidationSuccess, ValidationFailure } from './endpoint/index';
|
package/dist/server/index.js
CHANGED
|
@@ -18,3 +18,5 @@ export { getPolarClient, createPolarCheckout, createPolarCustomerPortalSession,
|
|
|
18
18
|
export { getResendClient, sendMagicLinkEmail, sendWelcomeEmail } from './email/index';
|
|
19
19
|
// Cloud Storage (AWS S3 & Cloudflare R2)
|
|
20
20
|
export { getS3Client, createPresignedUploadUrl, createS3UploadHandler } from './storage/index';
|
|
21
|
+
// Endpoint Pipeline, Guards & RFC 7807 Validation (Milestone 1)
|
|
22
|
+
export { yaxaEndpoint, createProblemDetails, problemResponse, isProblemDetails, requireAuth, requireRole, rateLimit, composeGuards, validateData } from './endpoint/index';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yaxa-svelte",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.2.0",
|
|
4
4
|
"description": "The Intuitive Svelte UI Library — Nuxt UI v4 equivalent for SvelteKit 2 & 3 & Svelte 5 with automated SEO, dynamic OpenGraph cards, sitemaps, robots.txt, and Tailwind CSS v4.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://yaxa.vercel.app",
|