@enderworld/onlyapi 1.5.1

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.
Files changed (160) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/LICENSE +21 -0
  3. package/README.md +338 -0
  4. package/dist/cli.js +14 -0
  5. package/package.json +69 -0
  6. package/src/application/dtos/admin.dto.ts +25 -0
  7. package/src/application/dtos/auth.dto.ts +97 -0
  8. package/src/application/dtos/index.ts +40 -0
  9. package/src/application/index.ts +2 -0
  10. package/src/application/services/admin.service.ts +150 -0
  11. package/src/application/services/api-key.service.ts +65 -0
  12. package/src/application/services/auth.service.ts +606 -0
  13. package/src/application/services/health.service.ts +97 -0
  14. package/src/application/services/index.ts +10 -0
  15. package/src/application/services/user.service.ts +95 -0
  16. package/src/cli/commands/help.ts +86 -0
  17. package/src/cli/commands/init.ts +301 -0
  18. package/src/cli/commands/upgrade.ts +471 -0
  19. package/src/cli/index.ts +76 -0
  20. package/src/cli/ui.ts +189 -0
  21. package/src/cluster.ts +62 -0
  22. package/src/core/entities/index.ts +1 -0
  23. package/src/core/entities/user.entity.ts +24 -0
  24. package/src/core/errors/app-error.ts +81 -0
  25. package/src/core/errors/index.ts +15 -0
  26. package/src/core/index.ts +7 -0
  27. package/src/core/ports/account-lockout.ts +15 -0
  28. package/src/core/ports/alert-sink.ts +27 -0
  29. package/src/core/ports/api-key.ts +37 -0
  30. package/src/core/ports/audit-log.ts +46 -0
  31. package/src/core/ports/cache.ts +24 -0
  32. package/src/core/ports/circuit-breaker.ts +42 -0
  33. package/src/core/ports/event-bus.ts +78 -0
  34. package/src/core/ports/index.ts +62 -0
  35. package/src/core/ports/job-queue.ts +73 -0
  36. package/src/core/ports/logger.ts +21 -0
  37. package/src/core/ports/metrics.ts +49 -0
  38. package/src/core/ports/oauth.ts +55 -0
  39. package/src/core/ports/password-hasher.ts +10 -0
  40. package/src/core/ports/password-history.ts +23 -0
  41. package/src/core/ports/password-policy.ts +43 -0
  42. package/src/core/ports/refresh-token-store.ts +37 -0
  43. package/src/core/ports/retry.ts +23 -0
  44. package/src/core/ports/token-blacklist.ts +16 -0
  45. package/src/core/ports/token-service.ts +23 -0
  46. package/src/core/ports/totp-service.ts +16 -0
  47. package/src/core/ports/user.repository.ts +40 -0
  48. package/src/core/ports/verification-token.ts +41 -0
  49. package/src/core/ports/webhook.ts +58 -0
  50. package/src/core/types/brand.ts +19 -0
  51. package/src/core/types/index.ts +19 -0
  52. package/src/core/types/pagination.ts +28 -0
  53. package/src/core/types/result.ts +52 -0
  54. package/src/infrastructure/alerting/index.ts +1 -0
  55. package/src/infrastructure/alerting/webhook.ts +100 -0
  56. package/src/infrastructure/cache/in-memory-cache.ts +111 -0
  57. package/src/infrastructure/cache/index.ts +6 -0
  58. package/src/infrastructure/cache/redis-cache.ts +204 -0
  59. package/src/infrastructure/config/config.ts +185 -0
  60. package/src/infrastructure/config/index.ts +1 -0
  61. package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
  62. package/src/infrastructure/database/index.ts +37 -0
  63. package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
  64. package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
  65. package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
  66. package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
  67. package/src/infrastructure/database/migrations/runner.ts +120 -0
  68. package/src/infrastructure/database/mssql/index.ts +14 -0
  69. package/src/infrastructure/database/mssql/migrations.ts +299 -0
  70. package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
  71. package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
  72. package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
  73. package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
  74. package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
  75. package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
  76. package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
  77. package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
  78. package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
  79. package/src/infrastructure/database/postgres/index.ts +14 -0
  80. package/src/infrastructure/database/postgres/migrations.ts +235 -0
  81. package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
  82. package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
  83. package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
  84. package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
  85. package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
  86. package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
  87. package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
  88. package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
  89. package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
  90. package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
  91. package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
  92. package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
  93. package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
  94. package/src/infrastructure/database/sqlite-password-history.ts +54 -0
  95. package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
  96. package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
  97. package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
  98. package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
  99. package/src/infrastructure/events/event-bus.ts +105 -0
  100. package/src/infrastructure/events/event-factory.ts +31 -0
  101. package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
  102. package/src/infrastructure/events/index.ts +4 -0
  103. package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
  104. package/src/infrastructure/index.ts +58 -0
  105. package/src/infrastructure/jobs/index.ts +1 -0
  106. package/src/infrastructure/jobs/job-queue.ts +185 -0
  107. package/src/infrastructure/logging/index.ts +1 -0
  108. package/src/infrastructure/logging/logger.ts +63 -0
  109. package/src/infrastructure/metrics/index.ts +1 -0
  110. package/src/infrastructure/metrics/prometheus.ts +231 -0
  111. package/src/infrastructure/oauth/github.ts +116 -0
  112. package/src/infrastructure/oauth/google.ts +83 -0
  113. package/src/infrastructure/oauth/index.ts +2 -0
  114. package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
  115. package/src/infrastructure/resilience/index.ts +2 -0
  116. package/src/infrastructure/resilience/retry.ts +50 -0
  117. package/src/infrastructure/security/account-lockout.ts +73 -0
  118. package/src/infrastructure/security/index.ts +6 -0
  119. package/src/infrastructure/security/password-hasher.ts +31 -0
  120. package/src/infrastructure/security/password-policy.ts +77 -0
  121. package/src/infrastructure/security/token-blacklist.ts +45 -0
  122. package/src/infrastructure/security/token-service.ts +144 -0
  123. package/src/infrastructure/security/totp-service.ts +142 -0
  124. package/src/infrastructure/tracing/index.ts +7 -0
  125. package/src/infrastructure/tracing/trace-context.ts +93 -0
  126. package/src/main.ts +479 -0
  127. package/src/presentation/context.ts +26 -0
  128. package/src/presentation/handlers/admin.handler.ts +114 -0
  129. package/src/presentation/handlers/api-key.handler.ts +68 -0
  130. package/src/presentation/handlers/auth.handler.ts +218 -0
  131. package/src/presentation/handlers/health.handler.ts +27 -0
  132. package/src/presentation/handlers/index.ts +15 -0
  133. package/src/presentation/handlers/metrics.handler.ts +21 -0
  134. package/src/presentation/handlers/oauth.handler.ts +61 -0
  135. package/src/presentation/handlers/openapi.handler.ts +543 -0
  136. package/src/presentation/handlers/response.ts +29 -0
  137. package/src/presentation/handlers/sse.handler.ts +165 -0
  138. package/src/presentation/handlers/user.handler.ts +81 -0
  139. package/src/presentation/handlers/webhook.handler.ts +92 -0
  140. package/src/presentation/handlers/websocket.handler.ts +226 -0
  141. package/src/presentation/i18n/index.ts +254 -0
  142. package/src/presentation/index.ts +5 -0
  143. package/src/presentation/middleware/api-key.ts +18 -0
  144. package/src/presentation/middleware/auth.ts +39 -0
  145. package/src/presentation/middleware/cors.ts +41 -0
  146. package/src/presentation/middleware/index.ts +12 -0
  147. package/src/presentation/middleware/rate-limit.ts +65 -0
  148. package/src/presentation/middleware/security-headers.ts +18 -0
  149. package/src/presentation/middleware/validate.ts +16 -0
  150. package/src/presentation/middleware/versioning.ts +69 -0
  151. package/src/presentation/routes/index.ts +1 -0
  152. package/src/presentation/routes/router.ts +272 -0
  153. package/src/presentation/server.ts +381 -0
  154. package/src/shared/cli.ts +294 -0
  155. package/src/shared/container.ts +65 -0
  156. package/src/shared/index.ts +2 -0
  157. package/src/shared/log-format.ts +148 -0
  158. package/src/shared/utils/id.ts +5 -0
  159. package/src/shared/utils/index.ts +2 -0
  160. package/src/shared/utils/timing-safe.ts +20 -0
@@ -0,0 +1,543 @@
1
+ /**
2
+ * OpenAPI 3.1 specification — auto-generated from route definitions and Zod schemas.
3
+ * Served at GET /docs (JSON) and GET /docs/html (embedded Swagger UI).
4
+ * Zero external dependencies.
5
+ */
6
+
7
+ import type { z } from "zod";
8
+ import { banUserDto, changeRoleDto } from "../../application/dtos/admin.dto.js";
9
+ import {
10
+ loginDto,
11
+ logoutDto,
12
+ refreshDto,
13
+ registerDto,
14
+ updateUserDto,
15
+ } from "../../application/dtos/auth.dto.js";
16
+
17
+ /** Extract JSON Schema-like structure from a Zod schema */
18
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: necessarily handles many Zod type variants
19
+ const zodToJsonSchema = (schema: z.ZodTypeAny): Record<string, unknown> => {
20
+ const def = schema._def;
21
+ const typeName = def.typeName as string;
22
+
23
+ if (typeName === "ZodObject") {
24
+ const shape = (schema as z.ZodObject<z.ZodRawShape>).shape;
25
+ const properties: Record<string, unknown> = {};
26
+ const required: string[] = [];
27
+
28
+ for (const [key, value] of Object.entries(shape)) {
29
+ const fieldSchema = value as z.ZodTypeAny;
30
+ const fieldDef = fieldSchema._def;
31
+ const isOptional = fieldDef.typeName === "ZodOptional";
32
+ const inner = isOptional ? fieldDef.innerType : fieldSchema;
33
+
34
+ properties[key] = zodToJsonSchema(inner);
35
+ if (!isOptional) required.push(key);
36
+ }
37
+
38
+ const result: Record<string, unknown> = { type: "object", properties };
39
+ if (required.length > 0) result["required"] = required;
40
+ return result;
41
+ }
42
+
43
+ if (typeName === "ZodString") {
44
+ const result: Record<string, unknown> = { type: "string" };
45
+ for (const check of def.checks ?? []) {
46
+ const c = check as { kind: string; value?: unknown };
47
+ if (c.kind === "email") result["format"] = "email";
48
+ if (c.kind === "min") result["minLength"] = c.value;
49
+ if (c.kind === "max") result["maxLength"] = c.value;
50
+ }
51
+ return result;
52
+ }
53
+
54
+ if (typeName === "ZodNumber") {
55
+ const result: Record<string, unknown> = { type: "number" };
56
+ for (const check of def.checks ?? []) {
57
+ const c = check as { kind: string; value?: unknown };
58
+ if (c.kind === "int") result["type"] = "integer";
59
+ if (c.kind === "min") result["minimum"] = c.value;
60
+ if (c.kind === "max") result["maximum"] = c.value;
61
+ }
62
+ return result;
63
+ }
64
+
65
+ if (typeName === "ZodEnum") {
66
+ return { type: "string", enum: def.values };
67
+ }
68
+
69
+ if (typeName === "ZodDefault") {
70
+ const inner = zodToJsonSchema(def.innerType);
71
+ return { ...inner, default: def.defaultValue() };
72
+ }
73
+
74
+ if (typeName === "ZodEffects") {
75
+ return zodToJsonSchema(def.schema);
76
+ }
77
+
78
+ if (typeName === "ZodOptional") {
79
+ return zodToJsonSchema(def.innerType);
80
+ }
81
+
82
+ return { type: "string" };
83
+ };
84
+
85
+ /** Error response schema (reused across all endpoints) */
86
+ const errorResponseSchema = {
87
+ type: "object",
88
+ properties: {
89
+ error: {
90
+ type: "object",
91
+ properties: {
92
+ code: { type: "string" },
93
+ message: { type: "string" },
94
+ details: { type: "object" },
95
+ },
96
+ required: ["code", "message"],
97
+ },
98
+ requestId: { type: "string" },
99
+ },
100
+ };
101
+
102
+ /** Token pair response */
103
+ const tokenPairSchema = {
104
+ type: "object",
105
+ properties: {
106
+ data: {
107
+ type: "object",
108
+ properties: {
109
+ accessToken: { type: "string" },
110
+ refreshToken: { type: "string" },
111
+ },
112
+ required: ["accessToken", "refreshToken"],
113
+ },
114
+ },
115
+ };
116
+
117
+ /** User view response */
118
+ const userViewSchema = {
119
+ type: "object",
120
+ properties: {
121
+ data: {
122
+ type: "object",
123
+ properties: {
124
+ id: { type: "string", format: "uuid" },
125
+ email: { type: "string", format: "email" },
126
+ role: { type: "string", enum: ["admin", "user"] },
127
+ createdAt: { type: "integer" },
128
+ updatedAt: { type: "integer" },
129
+ },
130
+ required: ["id", "email", "role", "createdAt", "updatedAt"],
131
+ },
132
+ },
133
+ };
134
+
135
+ /** Paginated user list response */
136
+ const paginatedUsersSchema = {
137
+ type: "object",
138
+ properties: {
139
+ data: {
140
+ type: "object",
141
+ properties: {
142
+ items: {
143
+ type: "array",
144
+ items: userViewSchema["properties"]["data"],
145
+ },
146
+ nextCursor: { type: ["string", "null"] },
147
+ hasMore: { type: "boolean" },
148
+ },
149
+ required: ["items", "nextCursor", "hasMore"],
150
+ },
151
+ },
152
+ };
153
+
154
+ const securitySchemes = {
155
+ BearerAuth: {
156
+ type: "http",
157
+ scheme: "bearer",
158
+ bearerFormat: "JWT",
159
+ },
160
+ };
161
+
162
+ const bearerSecurity = [{ BearerAuth: [] }];
163
+
164
+ /** Build the complete OpenAPI 3.1 specification */
165
+ export const buildOpenApiSpec = (): Record<string, unknown> => ({
166
+ openapi: "3.1.0",
167
+ info: {
168
+ title: "onlyApi",
169
+ version: "1.2.0",
170
+ description:
171
+ "Zero-dependency, enterprise-grade REST API built on Bun — fastest runtime, strictest TypeScript, cleanest architecture.",
172
+ license: { name: "MIT", url: "https://github.com/lysari/onlyapi/blob/main/LICENSE" },
173
+ contact: { name: "lysari", url: "https://github.com/lysari/onlyapi" },
174
+ },
175
+ servers: [{ url: "/", description: "Current server" }],
176
+ components: {
177
+ securitySchemes,
178
+ schemas: {
179
+ Error: errorResponseSchema,
180
+ TokenPair: tokenPairSchema,
181
+ UserView: userViewSchema,
182
+ PaginatedUsers: paginatedUsersSchema,
183
+ RegisterRequest: zodToJsonSchema(registerDto),
184
+ LoginRequest: zodToJsonSchema(loginDto),
185
+ RefreshRequest: zodToJsonSchema(refreshDto),
186
+ LogoutRequest: zodToJsonSchema(logoutDto),
187
+ UpdateUserRequest: zodToJsonSchema(updateUserDto),
188
+ ChangeRoleRequest: zodToJsonSchema(changeRoleDto),
189
+ BanUserRequest: zodToJsonSchema(banUserDto),
190
+ },
191
+ },
192
+ paths: {
193
+ "/health": {
194
+ get: {
195
+ tags: ["Health"],
196
+ summary: "Shallow health check",
197
+ description: "Returns instant health status for load balancer probes.",
198
+ responses: {
199
+ "200": {
200
+ description: "Healthy",
201
+ content: { "application/json": { schema: { type: "object" } } },
202
+ },
203
+ },
204
+ },
205
+ },
206
+ "/readiness": {
207
+ get: {
208
+ tags: ["Health"],
209
+ summary: "Deep readiness check",
210
+ description: "Runs full health check including database connectivity.",
211
+ responses: {
212
+ "200": {
213
+ description: "Ready",
214
+ content: { "application/json": { schema: { type: "object" } } },
215
+ },
216
+ "503": {
217
+ description: "Not ready",
218
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
219
+ },
220
+ },
221
+ },
222
+ },
223
+ "/api/v1/auth/register": {
224
+ post: {
225
+ tags: ["Auth"],
226
+ summary: "Register a new user",
227
+ requestBody: {
228
+ required: true,
229
+ content: {
230
+ "application/json": { schema: { $ref: "#/components/schemas/RegisterRequest" } },
231
+ },
232
+ },
233
+ responses: {
234
+ "201": {
235
+ description: "User registered",
236
+ content: { "application/json": { schema: { $ref: "#/components/schemas/TokenPair" } } },
237
+ },
238
+ "409": {
239
+ description: "Email already exists",
240
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
241
+ },
242
+ "422": {
243
+ description: "Validation failed",
244
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
245
+ },
246
+ },
247
+ },
248
+ },
249
+ "/api/v1/auth/login": {
250
+ post: {
251
+ tags: ["Auth"],
252
+ summary: "Login",
253
+ requestBody: {
254
+ required: true,
255
+ content: {
256
+ "application/json": { schema: { $ref: "#/components/schemas/LoginRequest" } },
257
+ },
258
+ },
259
+ responses: {
260
+ "200": {
261
+ description: "Login successful",
262
+ content: { "application/json": { schema: { $ref: "#/components/schemas/TokenPair" } } },
263
+ },
264
+ "401": {
265
+ description: "Invalid credentials",
266
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
267
+ },
268
+ "403": {
269
+ description: "Account locked",
270
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
271
+ },
272
+ },
273
+ },
274
+ },
275
+ "/api/v1/auth/refresh": {
276
+ post: {
277
+ tags: ["Auth"],
278
+ summary: "Refresh token",
279
+ requestBody: {
280
+ required: true,
281
+ content: {
282
+ "application/json": { schema: { $ref: "#/components/schemas/RefreshRequest" } },
283
+ },
284
+ },
285
+ responses: {
286
+ "200": {
287
+ description: "Token refreshed",
288
+ content: { "application/json": { schema: { $ref: "#/components/schemas/TokenPair" } } },
289
+ },
290
+ "401": {
291
+ description: "Invalid refresh token",
292
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
293
+ },
294
+ },
295
+ },
296
+ },
297
+ "/api/v1/auth/logout": {
298
+ post: {
299
+ tags: ["Auth"],
300
+ summary: "Logout",
301
+ security: bearerSecurity,
302
+ requestBody: {
303
+ required: true,
304
+ content: {
305
+ "application/json": { schema: { $ref: "#/components/schemas/LogoutRequest" } },
306
+ },
307
+ },
308
+ responses: {
309
+ "204": { description: "Logged out" },
310
+ "401": {
311
+ description: "Not authenticated",
312
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
313
+ },
314
+ },
315
+ },
316
+ },
317
+ "/api/v1/users/me": {
318
+ get: {
319
+ tags: ["Users"],
320
+ summary: "Get current user profile",
321
+ security: bearerSecurity,
322
+ responses: {
323
+ "200": {
324
+ description: "User profile",
325
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UserView" } } },
326
+ },
327
+ "401": {
328
+ description: "Not authenticated",
329
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
330
+ },
331
+ },
332
+ },
333
+ patch: {
334
+ tags: ["Users"],
335
+ summary: "Update current user profile",
336
+ security: bearerSecurity,
337
+ requestBody: {
338
+ required: true,
339
+ content: {
340
+ "application/json": { schema: { $ref: "#/components/schemas/UpdateUserRequest" } },
341
+ },
342
+ },
343
+ responses: {
344
+ "200": {
345
+ description: "User updated",
346
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UserView" } } },
347
+ },
348
+ "401": {
349
+ description: "Not authenticated",
350
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
351
+ },
352
+ "409": {
353
+ description: "Email conflict",
354
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
355
+ },
356
+ },
357
+ },
358
+ delete: {
359
+ tags: ["Users"],
360
+ summary: "Delete current user account",
361
+ security: bearerSecurity,
362
+ responses: {
363
+ "204": { description: "Account deleted" },
364
+ "401": {
365
+ description: "Not authenticated",
366
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
367
+ },
368
+ },
369
+ },
370
+ },
371
+ "/api/v1/admin/users": {
372
+ get: {
373
+ tags: ["Admin"],
374
+ summary: "List all users (paginated)",
375
+ security: bearerSecurity,
376
+ parameters: [
377
+ {
378
+ name: "cursor",
379
+ in: "query",
380
+ schema: { type: "string" },
381
+ description: "Pagination cursor",
382
+ },
383
+ {
384
+ name: "limit",
385
+ in: "query",
386
+ schema: { type: "integer", default: 20, minimum: 1, maximum: 100 },
387
+ description: "Page size",
388
+ },
389
+ {
390
+ name: "search",
391
+ in: "query",
392
+ schema: { type: "string" },
393
+ description: "Search by email",
394
+ },
395
+ {
396
+ name: "role",
397
+ in: "query",
398
+ schema: { type: "string", enum: ["admin", "user"] },
399
+ description: "Filter by role",
400
+ },
401
+ ],
402
+ responses: {
403
+ "200": {
404
+ description: "Paginated user list",
405
+ content: {
406
+ "application/json": { schema: { $ref: "#/components/schemas/PaginatedUsers" } },
407
+ },
408
+ },
409
+ "401": {
410
+ description: "Not authenticated",
411
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
412
+ },
413
+ },
414
+ },
415
+ },
416
+ "/api/v1/admin/users/{userId}": {
417
+ get: {
418
+ tags: ["Admin"],
419
+ summary: "Get user by ID",
420
+ security: bearerSecurity,
421
+ parameters: [{ name: "userId", in: "path", required: true, schema: { type: "string" } }],
422
+ responses: {
423
+ "200": {
424
+ description: "User detail",
425
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UserView" } } },
426
+ },
427
+ "404": {
428
+ description: "User not found",
429
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
430
+ },
431
+ },
432
+ },
433
+ },
434
+ "/api/v1/admin/users/{userId}/role": {
435
+ patch: {
436
+ tags: ["Admin"],
437
+ summary: "Change user role",
438
+ security: bearerSecurity,
439
+ parameters: [{ name: "userId", in: "path", required: true, schema: { type: "string" } }],
440
+ requestBody: {
441
+ required: true,
442
+ content: {
443
+ "application/json": { schema: { $ref: "#/components/schemas/ChangeRoleRequest" } },
444
+ },
445
+ },
446
+ responses: {
447
+ "200": {
448
+ description: "Role changed",
449
+ content: { "application/json": { schema: { $ref: "#/components/schemas/UserView" } } },
450
+ },
451
+ "403": {
452
+ description: "Cannot change own role",
453
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
454
+ },
455
+ },
456
+ },
457
+ },
458
+ "/api/v1/admin/users/{userId}/ban": {
459
+ post: {
460
+ tags: ["Admin"],
461
+ summary: "Ban a user",
462
+ security: bearerSecurity,
463
+ parameters: [{ name: "userId", in: "path", required: true, schema: { type: "string" } }],
464
+ requestBody: {
465
+ content: {
466
+ "application/json": { schema: { $ref: "#/components/schemas/BanUserRequest" } },
467
+ },
468
+ },
469
+ responses: {
470
+ "204": { description: "User banned" },
471
+ "403": {
472
+ description: "Cannot ban yourself",
473
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
474
+ },
475
+ },
476
+ },
477
+ },
478
+ "/api/v1/admin/users/{userId}/unban": {
479
+ post: {
480
+ tags: ["Admin"],
481
+ summary: "Unban a user",
482
+ security: bearerSecurity,
483
+ parameters: [{ name: "userId", in: "path", required: true, schema: { type: "string" } }],
484
+ responses: {
485
+ "204": { description: "User unbanned" },
486
+ "404": {
487
+ description: "User not found",
488
+ content: { "application/json": { schema: { $ref: "#/components/schemas/Error" } } },
489
+ },
490
+ },
491
+ },
492
+ },
493
+ },
494
+ tags: [
495
+ { name: "Health", description: "Health and readiness probes" },
496
+ { name: "Auth", description: "Authentication endpoints" },
497
+ { name: "Users", description: "User profile management" },
498
+ { name: "Admin", description: "Administrative user management (admin only)" },
499
+ ],
500
+ });
501
+
502
+ /** Serve the OpenAPI spec as JSON */
503
+ export const openApiHandler = (): { json: () => Response; html: () => Response } => {
504
+ let cachedSpec: string | null = null;
505
+ let cachedHtml: string | null = null;
506
+
507
+ return {
508
+ json: (): Response => {
509
+ if (cachedSpec === null) {
510
+ cachedSpec = JSON.stringify(buildOpenApiSpec(), null, 2);
511
+ }
512
+ return new Response(cachedSpec, {
513
+ status: 200,
514
+ headers: { "Content-Type": "application/json; charset=utf-8" },
515
+ });
516
+ },
517
+
518
+ html: (): Response => {
519
+ if (cachedHtml === null) {
520
+ cachedHtml = `<!DOCTYPE html>
521
+ <html lang="en">
522
+ <head>
523
+ <meta charset="utf-8">
524
+ <meta name="viewport" content="width=device-width, initial-scale=1">
525
+ <title>onlyApi — API Documentation</title>
526
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
527
+ </head>
528
+ <body>
529
+ <div id="swagger-ui"></div>
530
+ <script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
531
+ <script>
532
+ SwaggerUI({ url: '/docs', dom_id: '#swagger-ui', deepLinking: true });
533
+ </script>
534
+ </body>
535
+ </html>`;
536
+ }
537
+ return new Response(cachedHtml, {
538
+ status: 200,
539
+ headers: { "Content-Type": "text/html; charset=utf-8" },
540
+ });
541
+ },
542
+ };
543
+ };
@@ -0,0 +1,29 @@
1
+ import type { AppError } from "../../core/errors/app-error.js";
2
+ import { httpStatus } from "../../core/errors/app-error.js";
3
+
4
+ /**
5
+ * Serialise an AppError into a JSON response. Never leaks internals.
6
+ */
7
+ export const errorResponse = (error: AppError, requestId: string): Response => {
8
+ const status = httpStatus(error.code);
9
+ const body: Record<string, unknown> = {
10
+ error: {
11
+ code: error.code,
12
+ message: error.message,
13
+ ...(error.details ? { details: error.details } : {}),
14
+ },
15
+ requestId,
16
+ };
17
+
18
+ return Response.json(body, { status });
19
+ };
20
+
21
+ /** Success response helper */
22
+ export const jsonResponse = <T>(data: T, status = 200): Response =>
23
+ Response.json({ data }, { status });
24
+
25
+ /** 201 Created */
26
+ export const createdResponse = <T>(data: T): Response => Response.json({ data }, { status: 201 });
27
+
28
+ /** 204 No Content */
29
+ export const noContentResponse = (): Response => new Response(null, { status: 204 });