@mantiq/core 0.0.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 (82) hide show
  1. package/README.md +19 -0
  2. package/package.json +65 -0
  3. package/src/application/Application.ts +241 -0
  4. package/src/cache/CacheManager.ts +180 -0
  5. package/src/cache/FileCacheStore.ts +113 -0
  6. package/src/cache/MemcachedCacheStore.ts +115 -0
  7. package/src/cache/MemoryCacheStore.ts +62 -0
  8. package/src/cache/NullCacheStore.ts +39 -0
  9. package/src/cache/RedisCacheStore.ts +125 -0
  10. package/src/cache/events.ts +52 -0
  11. package/src/config/ConfigRepository.ts +115 -0
  12. package/src/config/env.ts +26 -0
  13. package/src/container/Container.ts +198 -0
  14. package/src/container/ContextualBindingBuilder.ts +21 -0
  15. package/src/contracts/Cache.ts +49 -0
  16. package/src/contracts/Config.ts +24 -0
  17. package/src/contracts/Container.ts +68 -0
  18. package/src/contracts/DriverManager.ts +16 -0
  19. package/src/contracts/Encrypter.ts +32 -0
  20. package/src/contracts/EventDispatcher.ts +32 -0
  21. package/src/contracts/ExceptionHandler.ts +20 -0
  22. package/src/contracts/Hasher.ts +19 -0
  23. package/src/contracts/Middleware.ts +23 -0
  24. package/src/contracts/Request.ts +54 -0
  25. package/src/contracts/Response.ts +19 -0
  26. package/src/contracts/Router.ts +62 -0
  27. package/src/contracts/ServiceProvider.ts +31 -0
  28. package/src/contracts/Session.ts +47 -0
  29. package/src/encryption/Encrypter.ts +197 -0
  30. package/src/encryption/errors.ts +30 -0
  31. package/src/errors/ConfigKeyNotFoundError.ts +7 -0
  32. package/src/errors/ContainerResolutionError.ts +13 -0
  33. package/src/errors/ForbiddenError.ts +7 -0
  34. package/src/errors/HttpError.ts +16 -0
  35. package/src/errors/MantiqError.ts +16 -0
  36. package/src/errors/NotFoundError.ts +7 -0
  37. package/src/errors/TokenMismatchError.ts +10 -0
  38. package/src/errors/TooManyRequestsError.ts +10 -0
  39. package/src/errors/UnauthorizedError.ts +7 -0
  40. package/src/errors/ValidationError.ts +10 -0
  41. package/src/exceptions/DevErrorPage.ts +564 -0
  42. package/src/exceptions/Handler.ts +118 -0
  43. package/src/hashing/Argon2Hasher.ts +46 -0
  44. package/src/hashing/BcryptHasher.ts +36 -0
  45. package/src/hashing/HashManager.ts +80 -0
  46. package/src/helpers/abort.ts +46 -0
  47. package/src/helpers/app.ts +17 -0
  48. package/src/helpers/cache.ts +12 -0
  49. package/src/helpers/config.ts +15 -0
  50. package/src/helpers/encrypt.ts +22 -0
  51. package/src/helpers/env.ts +1 -0
  52. package/src/helpers/hash.ts +20 -0
  53. package/src/helpers/response.ts +69 -0
  54. package/src/helpers/route.ts +24 -0
  55. package/src/helpers/session.ts +11 -0
  56. package/src/http/Cookie.ts +26 -0
  57. package/src/http/Kernel.ts +252 -0
  58. package/src/http/Request.ts +249 -0
  59. package/src/http/Response.ts +112 -0
  60. package/src/http/UploadedFile.ts +56 -0
  61. package/src/index.ts +97 -0
  62. package/src/macroable/Macroable.ts +174 -0
  63. package/src/middleware/Cors.ts +91 -0
  64. package/src/middleware/EncryptCookies.ts +101 -0
  65. package/src/middleware/Pipeline.ts +66 -0
  66. package/src/middleware/StartSession.ts +90 -0
  67. package/src/middleware/TrimStrings.ts +32 -0
  68. package/src/middleware/VerifyCsrfToken.ts +130 -0
  69. package/src/providers/CoreServiceProvider.ts +97 -0
  70. package/src/routing/ResourceRegistrar.ts +64 -0
  71. package/src/routing/Route.ts +40 -0
  72. package/src/routing/RouteCollection.ts +50 -0
  73. package/src/routing/RouteMatcher.ts +92 -0
  74. package/src/routing/Router.ts +280 -0
  75. package/src/routing/events.ts +19 -0
  76. package/src/session/SessionManager.ts +75 -0
  77. package/src/session/Store.ts +192 -0
  78. package/src/session/handlers/CookieSessionHandler.ts +42 -0
  79. package/src/session/handlers/FileSessionHandler.ts +79 -0
  80. package/src/session/handlers/MemorySessionHandler.ts +35 -0
  81. package/src/websocket/WebSocketContext.ts +20 -0
  82. package/src/websocket/WebSocketKernel.ts +60 -0
@@ -0,0 +1,130 @@
1
+ import type { Middleware, NextFunction } from '../contracts/Middleware.ts'
2
+ import type { MantiqRequest } from '../contracts/Request.ts'
3
+ import { TokenMismatchError } from '../errors/TokenMismatchError.ts'
4
+ import { AesEncrypter } from '../encryption/Encrypter.ts'
5
+ import { serializeCookie } from '../http/Cookie.ts'
6
+
7
+ /**
8
+ * CSRF protection middleware.
9
+ *
10
+ * Verifies that state-changing requests (POST, PUT, PATCH, DELETE)
11
+ * include a valid CSRF token. The token is checked against the session.
12
+ *
13
+ * Token can be provided via:
14
+ * - `_token` field in the request body
15
+ * - `X-CSRF-TOKEN` header (plain token)
16
+ * - `X-XSRF-TOKEN` header (encrypted token — set from the XSRF-TOKEN cookie)
17
+ *
18
+ * Also sets the XSRF-TOKEN cookie (readable by JavaScript) on every response.
19
+ */
20
+ export class VerifyCsrfToken implements Middleware {
21
+ /** URIs that should be excluded from CSRF verification. */
22
+ protected except: string[] = []
23
+
24
+ constructor(private readonly encrypter: AesEncrypter) {}
25
+
26
+ async handle(request: MantiqRequest, next: NextFunction): Promise<Response> {
27
+ if (this.shouldVerify(request) && !(await this.tokensMatch(request))) {
28
+ throw new TokenMismatchError()
29
+ }
30
+
31
+ const response = await next()
32
+
33
+ return this.addXsrfCookie(response, request)
34
+ }
35
+
36
+ private shouldVerify(request: MantiqRequest): boolean {
37
+ const method = request.method()
38
+
39
+ // Read-only methods don't need CSRF
40
+ if (['GET', 'HEAD', 'OPTIONS'].includes(method)) return false
41
+
42
+ // Check exclusions
43
+ const path = request.path()
44
+ return !this.except.some((pattern) => {
45
+ if (pattern.endsWith('*')) {
46
+ return path.startsWith(pattern.slice(0, -1))
47
+ }
48
+ return path === pattern
49
+ })
50
+ }
51
+
52
+ private async tokensMatch(request: MantiqRequest): Promise<boolean> {
53
+ if (!request.hasSession()) return false
54
+
55
+ const sessionToken = request.session().token()
56
+ const token = await this.getTokenFromRequest(request)
57
+
58
+ if (!token || !sessionToken) return false
59
+
60
+ return timingSafeEqual(token, sessionToken)
61
+ }
62
+
63
+ private async getTokenFromRequest(request: MantiqRequest): Promise<string | null> {
64
+ // 1. Check _token in body (form submission)
65
+ try {
66
+ const bodyToken = await request.input('_token')
67
+ if (bodyToken) return bodyToken
68
+ } catch {
69
+ // Body parsing might fail — continue
70
+ }
71
+
72
+ // 2. Check X-CSRF-TOKEN header (plain token, e.g. from meta tag)
73
+ const csrfHeader = request.header('x-csrf-token')
74
+ if (csrfHeader) return csrfHeader
75
+
76
+ // 3. Check X-XSRF-TOKEN header (encrypted, from cookie)
77
+ const xsrfHeader = request.header('x-xsrf-token')
78
+ if (xsrfHeader) {
79
+ try {
80
+ return await this.encrypter.decrypt(xsrfHeader)
81
+ } catch {
82
+ return null
83
+ }
84
+ }
85
+
86
+ return null
87
+ }
88
+
89
+ /**
90
+ * Add XSRF-TOKEN cookie to response (readable by JavaScript).
91
+ */
92
+ private addXsrfCookie(response: Response, request: MantiqRequest): Response {
93
+ if (!request.hasSession()) return response
94
+
95
+ const token = request.session().token()
96
+ const headers = new Headers(response.headers)
97
+
98
+ headers.append(
99
+ 'Set-Cookie',
100
+ serializeCookie('XSRF-TOKEN', token, {
101
+ path: '/',
102
+ httpOnly: false, // Must be readable by JS
103
+ sameSite: 'Lax',
104
+ }),
105
+ )
106
+
107
+ return new Response(response.body, {
108
+ status: response.status,
109
+ statusText: response.statusText,
110
+ headers,
111
+ })
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Constant-time string comparison to prevent timing attacks on token verification.
117
+ */
118
+ function timingSafeEqual(a: string, b: string): boolean {
119
+ if (a.length !== b.length) return false
120
+
121
+ const encoder = new TextEncoder()
122
+ const bufA = encoder.encode(a)
123
+ const bufB = encoder.encode(b)
124
+
125
+ let result = 0
126
+ for (let i = 0; i < bufA.length; i++) {
127
+ result |= bufA[i]! ^ bufB[i]!
128
+ }
129
+ return result === 0
130
+ }
@@ -0,0 +1,97 @@
1
+ import { ServiceProvider } from '../contracts/ServiceProvider.ts'
2
+ import { RouterImpl } from '../routing/Router.ts'
3
+ import { HttpKernel } from '../http/Kernel.ts'
4
+ import { WebSocketKernel } from '../websocket/WebSocketKernel.ts'
5
+ import { DefaultExceptionHandler } from '../exceptions/Handler.ts'
6
+ import { ConfigRepository } from '../config/ConfigRepository.ts'
7
+ import { CorsMiddleware } from '../middleware/Cors.ts'
8
+ import { TrimStringsMiddleware } from '../middleware/TrimStrings.ts'
9
+ import { StartSession } from '../middleware/StartSession.ts'
10
+ import { EncryptCookies } from '../middleware/EncryptCookies.ts'
11
+ import { VerifyCsrfToken } from '../middleware/VerifyCsrfToken.ts'
12
+ import { ROUTER } from '../helpers/route.ts'
13
+ import { ENCRYPTER } from '../helpers/encrypt.ts'
14
+ import { AesEncrypter } from '../encryption/Encrypter.ts'
15
+ import { HashManager } from '../hashing/HashManager.ts'
16
+ import { CacheManager } from '../cache/CacheManager.ts'
17
+ import { SessionManager } from '../session/SessionManager.ts'
18
+
19
+ // Symbols used as abstract keys for non-class bindings
20
+ export const HTTP_KERNEL = Symbol('HttpKernel')
21
+ export const EXCEPTION_HANDLER = Symbol('ExceptionHandler')
22
+ export const WS_KERNEL = Symbol('WebSocketKernel')
23
+
24
+ /**
25
+ * The first provider loaded. Registers all core framework bindings.
26
+ *
27
+ * Binding order matters within register():
28
+ * - ConfigRepository is already bound by Application.loadConfig() before any provider runs
29
+ * - This provider registers Router, Kernels, ExceptionHandler
30
+ */
31
+ export class CoreServiceProvider extends ServiceProvider {
32
+ override register(): void {
33
+ const configRepo = this.app.make(ConfigRepository)
34
+
35
+ // Router — singleton, receives config for URL generation
36
+ this.app.singleton(RouterImpl, (c) => {
37
+ const config = c.make(ConfigRepository)
38
+ return new RouterImpl(config)
39
+ })
40
+
41
+ // Alias the router under its symbol so route() helper can find it
42
+ this.app.alias(RouterImpl, ROUTER)
43
+
44
+ // WebSocket kernel — singleton, no deps
45
+ this.app.singleton(WebSocketKernel, () => new WebSocketKernel())
46
+
47
+ // Exception handler — singleton
48
+ this.app.singleton(DefaultExceptionHandler, () => new DefaultExceptionHandler())
49
+
50
+ // ── Encryption ────────────────────────────────────────────────────────
51
+ // AesEncrypter is async to create (key import), so we register a placeholder.
52
+ // Actual initialization happens in boot(). If no APP_KEY, encryption features
53
+ // will throw on use rather than on startup (graceful degradation for apps
54
+ // that don't need encryption).
55
+
56
+ // ── Hashing ───────────────────────────────────────────────────────────
57
+ this.app.singleton(HashManager, () => {
58
+ return new HashManager(configRepo.get('hashing', {}))
59
+ })
60
+
61
+ // ── Cache ─────────────────────────────────────────────────────────────
62
+ this.app.singleton(CacheManager, () => {
63
+ return new CacheManager(configRepo.get('cache', {}))
64
+ })
65
+
66
+ // ── Sessions ──────────────────────────────────────────────────────────
67
+ this.app.singleton(SessionManager, () => {
68
+ return new SessionManager(configRepo.get('session', {}))
69
+ })
70
+
71
+ // ── Built-in middleware ────────────────────────────────────────────────
72
+ this.app.singleton(CorsMiddleware, (c) => new CorsMiddleware(c.make(ConfigRepository)))
73
+ this.app.singleton(TrimStringsMiddleware, () => new TrimStringsMiddleware())
74
+ this.app.singleton(StartSession, (c) => new StartSession(c.make(SessionManager)))
75
+
76
+ // EncryptCookies and VerifyCsrfToken depend on AesEncrypter (set up in boot)
77
+ this.app.bind(EncryptCookies, (c) => new EncryptCookies(c.make<AesEncrypter>(ENCRYPTER)))
78
+ this.app.bind(VerifyCsrfToken, (c) => new VerifyCsrfToken(c.make<AesEncrypter>(ENCRYPTER)))
79
+
80
+ // HTTP kernel — singleton, depends on Router + ExceptionHandler + WsKernel
81
+ this.app.singleton(HttpKernel, (c) => {
82
+ const router = c.make(RouterImpl)
83
+ const exceptionHandler = c.make(DefaultExceptionHandler)
84
+ const wsKernel = c.make(WebSocketKernel)
85
+ return new HttpKernel(c, router, exceptionHandler, wsKernel)
86
+ })
87
+ }
88
+
89
+ override async boot(): Promise<void> {
90
+ // ── Encryption (async key import) ─────────────────────────────────────
91
+ const appKey = this.app.make(ConfigRepository).get('app.key', undefined) as string | undefined
92
+ if (appKey) {
93
+ const encrypter = await AesEncrypter.fromAppKey(appKey)
94
+ this.app.instance(ENCRYPTER, encrypter)
95
+ }
96
+ }
97
+ }
@@ -0,0 +1,64 @@
1
+ import type { Constructor } from '../contracts/Container.ts'
2
+ import type { Router } from '../contracts/Router.ts'
3
+
4
+ const RESOURCE_METHODS = ['index', 'create', 'store', 'show', 'edit', 'update', 'destroy'] as const
5
+ const API_RESOURCE_METHODS = ['index', 'store', 'show', 'update', 'destroy'] as const
6
+
7
+ /**
8
+ * Generates RESTful route sets for a controller.
9
+ *
10
+ * resource('photos', PhotoController) generates:
11
+ * GET /photos → index (photos.index)
12
+ * GET /photos/create → create (photos.create)
13
+ * POST /photos → store (photos.store)
14
+ * GET /photos/:photo → show (photos.show)
15
+ * GET /photos/:photo/edit → edit (photos.edit)
16
+ * PUT /photos/:photo → update (photos.update)
17
+ * DELETE /photos/:photo → destroy (photos.destroy)
18
+ *
19
+ * apiResource omits create + edit (those are frontend pages).
20
+ */
21
+ export class ResourceRegistrar {
22
+ constructor(private readonly router: Router) {}
23
+
24
+ register(name: string, controller: Constructor<any>, apiOnly = false): void {
25
+ const methods = apiOnly ? API_RESOURCE_METHODS : RESOURCE_METHODS
26
+ const param = this.singularize(name)
27
+ const prefix = `/${name}`
28
+
29
+ for (const method of methods) {
30
+ switch (method) {
31
+ case 'index':
32
+ this.router.get(prefix, [controller, 'index']).name(`${name}.index`)
33
+ break
34
+ case 'create':
35
+ this.router.get(`${prefix}/create`, [controller, 'create']).name(`${name}.create`)
36
+ break
37
+ case 'store':
38
+ this.router.post(prefix, [controller, 'store']).name(`${name}.store`)
39
+ break
40
+ case 'show':
41
+ this.router.get(`${prefix}/:${param}`, [controller, 'show']).name(`${name}.show`)
42
+ break
43
+ case 'edit':
44
+ this.router.get(`${prefix}/:${param}/edit`, [controller, 'edit']).name(`${name}.edit`)
45
+ break
46
+ case 'update':
47
+ this.router.match(['PUT', 'PATCH'], `${prefix}/:${param}`, [controller, 'update']).name(`${name}.update`)
48
+ break
49
+ case 'destroy':
50
+ this.router.delete(`${prefix}/:${param}`, [controller, 'destroy']).name(`${name}.destroy`)
51
+ break
52
+ }
53
+ }
54
+ }
55
+
56
+ private singularize(name: string): string {
57
+ // Simple singularization for common cases
58
+ const last = name.split('/').pop() ?? name
59
+ if (last.endsWith('ies')) return last.slice(0, -3) + 'y'
60
+ if (last.endsWith('ses') || last.endsWith('xes') || last.endsWith('zes')) return last.slice(0, -2)
61
+ if (last.endsWith('s') && !last.endsWith('ss')) return last.slice(0, -1)
62
+ return last
63
+ }
64
+ }
@@ -0,0 +1,40 @@
1
+ import type { HttpMethod, RouteAction, RouterRoute } from '../contracts/Router.ts'
2
+
3
+ export class Route implements RouterRoute {
4
+ public routeName?: string
5
+ public middlewareList: string[] = []
6
+ public wheres: Record<string, RegExp> = {}
7
+
8
+ constructor(
9
+ public readonly methods: HttpMethod[],
10
+ public readonly path: string,
11
+ public readonly action: RouteAction,
12
+ ) {}
13
+
14
+ name(name: string): this {
15
+ this.routeName = name
16
+ return this
17
+ }
18
+
19
+ middleware(...middleware: string[]): this {
20
+ this.middlewareList.push(...middleware)
21
+ return this
22
+ }
23
+
24
+ where(param: string, pattern: string | RegExp): this {
25
+ this.wheres[param] = typeof pattern === 'string' ? new RegExp(pattern) : pattern
26
+ return this
27
+ }
28
+
29
+ whereNumber(param: string): this {
30
+ return this.where(param, /^\d+$/)
31
+ }
32
+
33
+ whereAlpha(param: string): this {
34
+ return this.where(param, /^[a-zA-Z]+$/)
35
+ }
36
+
37
+ whereUuid(param: string): this {
38
+ return this.where(param, /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)
39
+ }
40
+ }
@@ -0,0 +1,50 @@
1
+ import type { HttpMethod } from '../contracts/Router.ts'
2
+ import { Route } from './Route.ts'
3
+
4
+ /**
5
+ * Stores all registered routes, indexed by HTTP method for fast lookup.
6
+ */
7
+ export class RouteCollection {
8
+ private routes = new Map<HttpMethod, Route[]>()
9
+ private namedRoutes = new Map<string, Route>()
10
+
11
+ add(route: Route): void {
12
+ const methods = Array.isArray(route.methods) ? route.methods : [route.methods]
13
+ for (const method of methods) {
14
+ if (!this.routes.has(method)) this.routes.set(method, [])
15
+ this.routes.get(method)!.push(route)
16
+ }
17
+ if (route.routeName) {
18
+ this.namedRoutes.set(route.routeName, route)
19
+ }
20
+ }
21
+
22
+ getByMethod(method: HttpMethod): Route[] {
23
+ return this.routes.get(method) ?? []
24
+ }
25
+
26
+ getByName(name: string): Route | undefined {
27
+ return this.namedRoutes.get(name)
28
+ }
29
+
30
+ /** Update named route index when a name is set after route registration. */
31
+ indexName(route: Route): void {
32
+ if (route.routeName) {
33
+ this.namedRoutes.set(route.routeName, route)
34
+ }
35
+ }
36
+
37
+ all(): Route[] {
38
+ const seen = new Set<Route>()
39
+ const result: Route[] = []
40
+ for (const routes of this.routes.values()) {
41
+ for (const route of routes) {
42
+ if (!seen.has(route)) {
43
+ seen.add(route)
44
+ result.push(route)
45
+ }
46
+ }
47
+ }
48
+ return result
49
+ }
50
+ }
@@ -0,0 +1,92 @@
1
+ import type { Route } from './Route.ts'
2
+
3
+ export interface MatchResult {
4
+ route: Route
5
+ params: Record<string, any>
6
+ }
7
+
8
+ /**
9
+ * Matches a URL path against a route pattern.
10
+ *
11
+ * Supported syntax:
12
+ * - Static segments: /users/profile
13
+ * - Required params: /users/:id
14
+ * - Optional params: /posts/:slug?
15
+ * - Wildcard (end only): /files/*
16
+ */
17
+ export class RouteMatcher {
18
+ static match(route: Route, pathname: string): MatchResult | null {
19
+ const params: Record<string, any> = {}
20
+ const routeSegments = route.path.split('/').filter(Boolean)
21
+ const pathSegments = pathname.split('/').filter(Boolean)
22
+
23
+ let routeIdx = 0
24
+ let pathIdx = 0
25
+
26
+ while (routeIdx < routeSegments.length) {
27
+ const seg = routeSegments[routeIdx]!
28
+
29
+ // Wildcard — matches the rest
30
+ if (seg === '*') {
31
+ params['*'] = pathSegments.slice(pathIdx).join('/')
32
+ return this.checkConstraints(route, params) ? { route, params } : null
33
+ }
34
+
35
+ // Optional param
36
+ if (seg.startsWith(':') && seg.endsWith('?')) {
37
+ const name = seg.slice(1, -1)
38
+ if (pathIdx < pathSegments.length) {
39
+ params[name] = pathSegments[pathIdx]
40
+ pathIdx++
41
+ } else {
42
+ params[name] = undefined
43
+ }
44
+ routeIdx++
45
+ continue
46
+ }
47
+
48
+ // Required param
49
+ if (seg.startsWith(':')) {
50
+ const name = seg.slice(1)
51
+ if (pathIdx >= pathSegments.length) return null
52
+ params[name] = pathSegments[pathIdx]
53
+ pathIdx++
54
+ routeIdx++
55
+ continue
56
+ }
57
+
58
+ // Static segment
59
+ if (pathIdx >= pathSegments.length || pathSegments[pathIdx] !== seg) {
60
+ return null
61
+ }
62
+
63
+ pathIdx++
64
+ routeIdx++
65
+ }
66
+
67
+ // All route segments consumed — path must also be fully consumed
68
+ if (pathIdx !== pathSegments.length) return null
69
+
70
+ // Check constraints
71
+ if (!this.checkConstraints(route, params)) return null
72
+
73
+ // Coerce numeric params
74
+ for (const [key, regex] of Object.entries(route.wheres)) {
75
+ if (regex.source === '^\\d+$' && params[key] !== undefined) {
76
+ params[key] = Number(params[key])
77
+ }
78
+ }
79
+
80
+ return { route, params }
81
+ }
82
+
83
+ private static checkConstraints(route: Route, params: Record<string, any>): boolean {
84
+ for (const [param, pattern] of Object.entries(route.wheres)) {
85
+ const value = params[param]
86
+ if (value !== undefined && !pattern.test(String(value))) {
87
+ return false
88
+ }
89
+ }
90
+ return true
91
+ }
92
+ }