@strav/http 0.1.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/package.json +50 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +87 -0
- package/src/auth/index.ts +7 -0
- package/src/auth/middleware/authenticate.ts +64 -0
- package/src/auth/middleware/csrf.ts +62 -0
- package/src/auth/middleware/guest.ts +46 -0
- package/src/http/context.ts +220 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +18 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/resource.ts +102 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +159 -0
- package/src/index.ts +7 -0
- package/src/middleware/http_cache.ts +106 -0
- package/src/middleware/i18n.ts +84 -0
- package/src/middleware/request_logger.ts +19 -0
- package/src/policy/authorize.ts +44 -0
- package/src/policy/index.ts +3 -0
- package/src/policy/policy_result.ts +13 -0
- package/src/providers/auth_provider.ts +35 -0
- package/src/providers/http_provider.ts +27 -0
- package/src/providers/index.ts +7 -0
- package/src/providers/session_provider.ts +29 -0
- package/src/providers/view_provider.ts +18 -0
- package/src/session/index.ts +4 -0
- package/src/session/middleware.ts +46 -0
- package/src/session/session.ts +308 -0
- package/src/session/session_manager.ts +83 -0
- package/src/validation/index.ts +18 -0
- package/src/validation/rules.ts +170 -0
- package/src/validation/validate.ts +41 -0
- package/src/view/cache.ts +47 -0
- package/src/view/client/islands.ts +84 -0
- package/src/view/compiler.ts +199 -0
- package/src/view/engine.ts +139 -0
- package/src/view/escape.ts +14 -0
- package/src/view/index.ts +13 -0
- package/src/view/islands/island_builder.ts +338 -0
- package/src/view/islands/vue_plugin.ts +136 -0
- package/src/view/middleware/static.ts +69 -0
- package/src/view/tokenizer.ts +182 -0
- package/tsconfig.json +5 -0
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@strav/http",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "HTTP layer for the Strav framework — router, server, middleware, authentication, sessions, validation, and views",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"bun",
|
|
9
|
+
"framework",
|
|
10
|
+
"typescript",
|
|
11
|
+
"strav",
|
|
12
|
+
"http"
|
|
13
|
+
],
|
|
14
|
+
"files": [
|
|
15
|
+
"src/",
|
|
16
|
+
"package.json",
|
|
17
|
+
"tsconfig.json",
|
|
18
|
+
"CHANGELOG.md"
|
|
19
|
+
],
|
|
20
|
+
"exports": {
|
|
21
|
+
".": "./src/index.ts",
|
|
22
|
+
"./http": "./src/http/index.ts",
|
|
23
|
+
"./http/*": "./src/http/*.ts",
|
|
24
|
+
"./view": "./src/view/index.ts",
|
|
25
|
+
"./view/*": "./src/view/*.ts",
|
|
26
|
+
"./session": "./src/session/index.ts",
|
|
27
|
+
"./session/*": "./src/session/*.ts",
|
|
28
|
+
"./validation": "./src/validation/index.ts",
|
|
29
|
+
"./validation/*": "./src/validation/*.ts",
|
|
30
|
+
"./policy": "./src/policy/index.ts",
|
|
31
|
+
"./policy/*": "./src/policy/*.ts",
|
|
32
|
+
"./auth": "./src/auth/index.ts",
|
|
33
|
+
"./auth/*": "./src/auth/*.ts",
|
|
34
|
+
"./providers": "./src/providers/index.ts",
|
|
35
|
+
"./providers/*": "./src/providers/*.ts"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@strav/kernel": "0.1.0",
|
|
39
|
+
"@strav/database": "0.1.0"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@vue/compiler-sfc": "^3.5.28",
|
|
43
|
+
"luxon": "^3.7.2",
|
|
44
|
+
"@types/luxon": "^3.7.1"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"test": "bun test tests/",
|
|
48
|
+
"typecheck": "tsc --noEmit"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import Auth, { extractUserId, randomHex } from './auth.ts'
|
|
2
|
+
|
|
3
|
+
/** The DB record for an access token (never contains the plain token). */
|
|
4
|
+
export interface AccessTokenData {
|
|
5
|
+
id: number
|
|
6
|
+
userId: string
|
|
7
|
+
name: string
|
|
8
|
+
lastUsedAt: Date | null
|
|
9
|
+
expiresAt: Date | null
|
|
10
|
+
createdAt: Date
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function hashToken(plain: string): string {
|
|
14
|
+
return new Bun.CryptoHasher('sha256').update(plain).digest('hex')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Opaque, SHA-256-hashed access tokens stored in the database.
|
|
19
|
+
*
|
|
20
|
+
* The plain-text token is returned exactly once at creation time and
|
|
21
|
+
* is never stored. Even if the database leaks, tokens cannot be recovered.
|
|
22
|
+
*
|
|
23
|
+
* @example
|
|
24
|
+
* // Create
|
|
25
|
+
* const { token, accessToken } = await AccessToken.create(user, 'mobile-app')
|
|
26
|
+
* // token = 'a1b2c3...' (give to the client, shown once)
|
|
27
|
+
*
|
|
28
|
+
* // Validate (used internally by the auth middleware)
|
|
29
|
+
* const record = await AccessToken.validate(plainToken)
|
|
30
|
+
*
|
|
31
|
+
* // Revoke
|
|
32
|
+
* await AccessToken.revoke(accessToken.id)
|
|
33
|
+
*/
|
|
34
|
+
export default class AccessToken {
|
|
35
|
+
/**
|
|
36
|
+
* Generate a new access token for the given user.
|
|
37
|
+
* Returns the plain-text token (shown once) and the database record.
|
|
38
|
+
*/
|
|
39
|
+
static async create(
|
|
40
|
+
user: unknown,
|
|
41
|
+
name: string
|
|
42
|
+
): Promise<{ token: string; accessToken: AccessTokenData }> {
|
|
43
|
+
const userId = extractUserId(user)
|
|
44
|
+
const plain = randomHex(32) // 64-char hex string
|
|
45
|
+
const hash = hashToken(plain)
|
|
46
|
+
|
|
47
|
+
const expCfg = Auth.config.token.expiration
|
|
48
|
+
const expiresAt = expCfg ? new Date(Date.now() + expCfg * 60_000) : null
|
|
49
|
+
|
|
50
|
+
const rows = await Auth.db.sql`
|
|
51
|
+
INSERT INTO "_strav_access_tokens" ("user_id", "name", "token", "expires_at")
|
|
52
|
+
VALUES (${userId}, ${name}, ${hash}, ${expiresAt})
|
|
53
|
+
RETURNING *
|
|
54
|
+
`
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
token: plain,
|
|
58
|
+
accessToken: AccessToken.hydrate(rows[0] as Record<string, unknown>),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Validate a plain-text token. Returns the token record if valid, null otherwise.
|
|
64
|
+
* Automatically rejects expired tokens and updates last_used_at.
|
|
65
|
+
*/
|
|
66
|
+
static async validate(plainToken: string): Promise<AccessTokenData | null> {
|
|
67
|
+
const hash = hashToken(plainToken)
|
|
68
|
+
|
|
69
|
+
const rows = await Auth.db.sql`
|
|
70
|
+
SELECT * FROM "_strav_access_tokens" WHERE "token" = ${hash} LIMIT 1
|
|
71
|
+
`
|
|
72
|
+
if (rows.length === 0) return null
|
|
73
|
+
|
|
74
|
+
const record = AccessToken.hydrate(rows[0] as Record<string, unknown>)
|
|
75
|
+
|
|
76
|
+
if (record.expiresAt && record.expiresAt.getTime() < Date.now()) {
|
|
77
|
+
return null
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Update last_used_at (fire-and-forget)
|
|
81
|
+
Auth.db.sql`
|
|
82
|
+
UPDATE "_strav_access_tokens"
|
|
83
|
+
SET "last_used_at" = NOW()
|
|
84
|
+
WHERE "id" = ${record.id}
|
|
85
|
+
`.then(
|
|
86
|
+
() => {},
|
|
87
|
+
() => {}
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return record
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Revoke (delete) a single token by its database ID. */
|
|
94
|
+
static async revoke(id: number): Promise<void> {
|
|
95
|
+
await Auth.db.sql`
|
|
96
|
+
DELETE FROM "_strav_access_tokens" WHERE "id" = ${id}
|
|
97
|
+
`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Revoke all tokens belonging to a user. */
|
|
101
|
+
static async revokeAllFor(user: unknown): Promise<void> {
|
|
102
|
+
const userId = extractUserId(user)
|
|
103
|
+
await Auth.db.sql`
|
|
104
|
+
DELETE FROM "_strav_access_tokens" WHERE "user_id" = ${userId}
|
|
105
|
+
`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Internal
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
private static hydrate(row: Record<string, unknown>): AccessTokenData {
|
|
113
|
+
return {
|
|
114
|
+
id: row.id as number,
|
|
115
|
+
userId: row.user_id as string,
|
|
116
|
+
name: row.name as string,
|
|
117
|
+
lastUsedAt: (row.last_used_at as Date) ?? null,
|
|
118
|
+
expiresAt: (row.expires_at as Date) ?? null,
|
|
119
|
+
createdAt: row.created_at as Date,
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/auth/auth.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { inject } from '@stravigor/kernel/core/inject'
|
|
2
|
+
import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
|
|
3
|
+
import Configuration from '@stravigor/kernel/config/configuration'
|
|
4
|
+
import Database from '@stravigor/database/database/database'
|
|
5
|
+
|
|
6
|
+
// Re-export helpers that were originally defined here
|
|
7
|
+
export { extractUserId } from '@stravigor/database/helpers/identity'
|
|
8
|
+
export { randomHex } from '@stravigor/kernel/helpers/crypto'
|
|
9
|
+
|
|
10
|
+
export interface TokenConfig {
|
|
11
|
+
expiration: number | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface AuthConfig {
|
|
15
|
+
default: string
|
|
16
|
+
token: TokenConfig
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type UserResolver = (id: string | number) => Promise<unknown>
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Central auth configuration hub.
|
|
23
|
+
*
|
|
24
|
+
* Resolved once via the DI container — stores the database reference,
|
|
25
|
+
* parsed config, and user resolver for AccessToken and auth middleware.
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* app.singleton(Auth)
|
|
29
|
+
* app.resolve(Auth)
|
|
30
|
+
* Auth.useResolver((id) => User.find(id))
|
|
31
|
+
* await Auth.ensureTables()
|
|
32
|
+
*/
|
|
33
|
+
@inject
|
|
34
|
+
export default class Auth {
|
|
35
|
+
private static _db: Database
|
|
36
|
+
private static _config: AuthConfig
|
|
37
|
+
private static _resolver: UserResolver
|
|
38
|
+
|
|
39
|
+
constructor(db: Database, config: Configuration) {
|
|
40
|
+
Auth._db = db
|
|
41
|
+
Auth._config = {
|
|
42
|
+
default: config.get('auth.default', 'session') as string,
|
|
43
|
+
token: {
|
|
44
|
+
expiration: null,
|
|
45
|
+
...(config.get('auth.token', {}) as object),
|
|
46
|
+
},
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Register the function used to load a user by ID. */
|
|
51
|
+
static useResolver(resolver: UserResolver): void {
|
|
52
|
+
Auth._resolver = resolver
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
static get db(): Database {
|
|
56
|
+
if (!Auth._db)
|
|
57
|
+
throw new ConfigurationError('Auth not configured. Resolve Auth through the container first.')
|
|
58
|
+
return Auth._db
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
static get config(): AuthConfig {
|
|
62
|
+
return Auth._config
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Load a user by ID using the registered resolver. */
|
|
66
|
+
static async resolveUser(id: string | number): Promise<unknown> {
|
|
67
|
+
if (!Auth._resolver) {
|
|
68
|
+
throw new ConfigurationError('Auth resolver not configured. Call Auth.useResolver() first.')
|
|
69
|
+
}
|
|
70
|
+
return Auth._resolver(id)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Create the internal access_tokens table if it doesn't exist. */
|
|
74
|
+
static async ensureTables(): Promise<void> {
|
|
75
|
+
await Auth.db.sql`
|
|
76
|
+
CREATE TABLE IF NOT EXISTS "_strav_access_tokens" (
|
|
77
|
+
"id" SERIAL PRIMARY KEY,
|
|
78
|
+
"user_id" VARCHAR(255) NOT NULL,
|
|
79
|
+
"name" VARCHAR(255) NOT NULL,
|
|
80
|
+
"token" VARCHAR(64) NOT NULL UNIQUE,
|
|
81
|
+
"last_used_at" TIMESTAMPTZ,
|
|
82
|
+
"expires_at" TIMESTAMPTZ,
|
|
83
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
84
|
+
)
|
|
85
|
+
`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { default as Auth } from './auth.ts'
|
|
2
|
+
export { default as AccessToken } from './access_token.ts'
|
|
3
|
+
export { auth } from './middleware/authenticate.ts'
|
|
4
|
+
export { csrf } from './middleware/csrf.ts'
|
|
5
|
+
export { guest } from './middleware/guest.ts'
|
|
6
|
+
export type { AuthConfig, TokenConfig } from './auth.ts'
|
|
7
|
+
export type { AccessTokenData } from './access_token.ts'
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Middleware } from '../../http/middleware.ts'
|
|
2
|
+
import Auth from '../auth.ts'
|
|
3
|
+
import type Session from '../../session/session.ts'
|
|
4
|
+
import AccessToken from '../access_token.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Require the request to be authenticated.
|
|
8
|
+
*
|
|
9
|
+
* For the session guard, requires the `session()` middleware to run first
|
|
10
|
+
* so that `ctx.get('session')` is available. Checks that the session has
|
|
11
|
+
* a user associated with it.
|
|
12
|
+
*
|
|
13
|
+
* Sets:
|
|
14
|
+
* - `ctx.get('user')` — the resolved user object
|
|
15
|
+
* - `ctx.get('accessToken')` — the AccessTokenData (token guard only)
|
|
16
|
+
*
|
|
17
|
+
* @param guard 'session' | 'token' (defaults to config `auth.default`)
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* router.group({ middleware: [session(), auth()] }, (r) => { ... })
|
|
21
|
+
* router.group({ middleware: [auth('token')] }, (r) => { ... })
|
|
22
|
+
*/
|
|
23
|
+
export function auth(guard?: string): Middleware {
|
|
24
|
+
return async (ctx, next) => {
|
|
25
|
+
const guardName = guard ?? Auth.config.default
|
|
26
|
+
|
|
27
|
+
if (guardName === 'session') {
|
|
28
|
+
const session = ctx.get<Session>('session')
|
|
29
|
+
|
|
30
|
+
if (!session || !session.isAuthenticated || session.isExpired()) {
|
|
31
|
+
return ctx.json({ error: 'Unauthenticated' }, 401)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const user = await Auth.resolveUser(session.userId!)
|
|
35
|
+
if (!user) return ctx.json({ error: 'Unauthenticated' }, 401)
|
|
36
|
+
|
|
37
|
+
ctx.set('user', user)
|
|
38
|
+
|
|
39
|
+
const response = await next()
|
|
40
|
+
await session.touch()
|
|
41
|
+
return response
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (guardName === 'token') {
|
|
45
|
+
const header = ctx.header('authorization')
|
|
46
|
+
if (!header?.startsWith('Bearer ')) {
|
|
47
|
+
return ctx.json({ error: 'Unauthenticated' }, 401)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const accessToken = await AccessToken.validate(header.slice(7))
|
|
51
|
+
if (!accessToken) return ctx.json({ error: 'Unauthenticated' }, 401)
|
|
52
|
+
|
|
53
|
+
const user = await Auth.resolveUser(accessToken.userId)
|
|
54
|
+
if (!user) return ctx.json({ error: 'Unauthenticated' }, 401)
|
|
55
|
+
|
|
56
|
+
ctx.set('user', user)
|
|
57
|
+
ctx.set('accessToken', accessToken)
|
|
58
|
+
|
|
59
|
+
return next()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return ctx.json({ error: `Unknown auth guard: ${guardName}` }, 500)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Middleware } from '../../http/middleware.ts'
|
|
2
|
+
import type Session from '../../session/session.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CSRF protection middleware.
|
|
6
|
+
*
|
|
7
|
+
* Must be placed **after** the `session()` middleware so that
|
|
8
|
+
* `ctx.get('session')` is available. Works for both anonymous and
|
|
9
|
+
* authenticated sessions.
|
|
10
|
+
*
|
|
11
|
+
* On safe methods (GET, HEAD, OPTIONS) the CSRF token is made available
|
|
12
|
+
* via `ctx.get('csrfToken')` for embedding in forms or meta tags.
|
|
13
|
+
*
|
|
14
|
+
* On state-changing methods, the middleware checks for a matching token in:
|
|
15
|
+
* 1. `X-CSRF-Token` header
|
|
16
|
+
* 2. `X-XSRF-Token` header
|
|
17
|
+
* 3. `_token` field in a JSON or form body
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* router.group({ middleware: [session(), csrf()] }, (r) => {
|
|
21
|
+
* r.post('/login', handleLogin)
|
|
22
|
+
* })
|
|
23
|
+
*/
|
|
24
|
+
export function csrf(): Middleware {
|
|
25
|
+
return async (ctx, next) => {
|
|
26
|
+
const session = ctx.get<Session>('session')
|
|
27
|
+
|
|
28
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(ctx.method)) {
|
|
29
|
+
if (session) ctx.set('csrfToken', session.csrfToken)
|
|
30
|
+
return next()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!session) {
|
|
34
|
+
return ctx.json({ error: 'Session required for CSRF protection' }, 403)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Check headers first
|
|
38
|
+
let token = ctx.header('X-CSRF-Token') ?? ctx.header('X-XSRF-Token')
|
|
39
|
+
|
|
40
|
+
// Fall back to request body
|
|
41
|
+
if (!token) {
|
|
42
|
+
const contentType = ctx.header('content-type') ?? ''
|
|
43
|
+
|
|
44
|
+
if (contentType.includes('application/json')) {
|
|
45
|
+
const body = await ctx.body<Record<string, unknown>>()
|
|
46
|
+
if (typeof body._token === 'string') token = body._token
|
|
47
|
+
} else if (
|
|
48
|
+
contentType.includes('application/x-www-form-urlencoded') ||
|
|
49
|
+
contentType.includes('multipart/form-data')
|
|
50
|
+
) {
|
|
51
|
+
const body = await ctx.body<FormData>()
|
|
52
|
+
if (body instanceof FormData) token = body.get('_token') as string | null
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!token || token !== session.csrfToken) {
|
|
57
|
+
return ctx.json({ error: 'CSRF token mismatch' }, 403)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return next()
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { Middleware } from '../../http/middleware.ts'
|
|
2
|
+
import Auth from '../auth.ts'
|
|
3
|
+
import type Session from '../../session/session.ts'
|
|
4
|
+
import AccessToken from '../access_token.ts'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Only allow unauthenticated requests through.
|
|
8
|
+
*
|
|
9
|
+
* For the session guard, requires the `session()` middleware to run first
|
|
10
|
+
* so that `ctx.get('session')` is available.
|
|
11
|
+
*
|
|
12
|
+
* Useful for login/register pages that should not be accessible to
|
|
13
|
+
* users who are already logged in.
|
|
14
|
+
*
|
|
15
|
+
* @param redirectTo If provided, authenticated users are redirected here
|
|
16
|
+
* instead of receiving a 403.
|
|
17
|
+
*
|
|
18
|
+
* @example
|
|
19
|
+
* router.group({ middleware: [session(), guest('/dashboard')] }, (r) => {
|
|
20
|
+
* r.get('/login', showLoginPage)
|
|
21
|
+
* })
|
|
22
|
+
*/
|
|
23
|
+
export function guest(redirectTo?: string): Middleware {
|
|
24
|
+
return async (ctx, next) => {
|
|
25
|
+
const guardName = Auth.config.default
|
|
26
|
+
let isAuthenticated = false
|
|
27
|
+
|
|
28
|
+
if (guardName === 'session') {
|
|
29
|
+
const session = ctx.get<Session>('session')
|
|
30
|
+
isAuthenticated = session !== null && session.isAuthenticated && !session.isExpired()
|
|
31
|
+
} else if (guardName === 'token') {
|
|
32
|
+
const header = ctx.header('authorization')
|
|
33
|
+
if (header?.startsWith('Bearer ')) {
|
|
34
|
+
const accessToken = await AccessToken.validate(header.slice(7))
|
|
35
|
+
isAuthenticated = accessToken !== null
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (isAuthenticated) {
|
|
40
|
+
if (redirectTo) return ctx.redirect(redirectTo)
|
|
41
|
+
return ctx.json({ error: 'Already authenticated' }, 403)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return next()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { parseCookies } from './cookie.ts'
|
|
2
|
+
import type { ViewEngine } from '@stravigor/view'
|
|
3
|
+
import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* HTTP request context — the primary object handlers interact with.
|
|
7
|
+
*
|
|
8
|
+
* Wraps Bun's native Request and adds route params, body parsing,
|
|
9
|
+
* response helpers, and a type-safe state bag for middleware.
|
|
10
|
+
*/
|
|
11
|
+
export default class Context {
|
|
12
|
+
private static _viewEngine: ViewEngine | null = null
|
|
13
|
+
|
|
14
|
+
static setViewEngine(engine: ViewEngine): void {
|
|
15
|
+
Context._viewEngine = engine
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
readonly url: URL
|
|
19
|
+
readonly method: string
|
|
20
|
+
readonly path: string
|
|
21
|
+
readonly headers: Headers
|
|
22
|
+
|
|
23
|
+
private _state = new Map<string, unknown>()
|
|
24
|
+
private _subdomain?: string
|
|
25
|
+
private _query?: URLSearchParams
|
|
26
|
+
private _cookies?: Map<string, string>
|
|
27
|
+
private _body?: unknown
|
|
28
|
+
private _bodyParsed = false
|
|
29
|
+
|
|
30
|
+
constructor(
|
|
31
|
+
readonly request: Request,
|
|
32
|
+
readonly params: Record<string, string> = {},
|
|
33
|
+
private domain: string = 'localhost'
|
|
34
|
+
) {
|
|
35
|
+
this.url = new URL(request.url)
|
|
36
|
+
this.method = request.method
|
|
37
|
+
this.path = this.url.pathname
|
|
38
|
+
this.headers = request.headers
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Request helpers
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
/** Parsed query string parameters. */
|
|
46
|
+
get query(): URLSearchParams {
|
|
47
|
+
if (!this._query) this._query = this.url.searchParams
|
|
48
|
+
return this._query
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Subdomain extracted from the Host header relative to the configured domain. */
|
|
52
|
+
get subdomain(): string {
|
|
53
|
+
if (this._subdomain !== undefined) return this._subdomain
|
|
54
|
+
|
|
55
|
+
const host = this.headers.get('host') ?? ''
|
|
56
|
+
const hostname = host.split(':')[0] ?? ''
|
|
57
|
+
|
|
58
|
+
if (hostname.endsWith(this.domain) && hostname.length > this.domain.length) {
|
|
59
|
+
this._subdomain = hostname.slice(0, -(this.domain.length + 1))
|
|
60
|
+
} else {
|
|
61
|
+
this._subdomain = ''
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return this._subdomain
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Shorthand for reading a single request header. */
|
|
68
|
+
header(name: string): string | null {
|
|
69
|
+
return this.headers.get(name)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Read a query string parameter, with optional typed default. */
|
|
73
|
+
qs(name: string): string | null
|
|
74
|
+
qs(name: string, defaultValue: number): number
|
|
75
|
+
qs(name: string, defaultValue: string): string
|
|
76
|
+
qs(name: string, defaultValue?: string | number): string | number | null {
|
|
77
|
+
const value = this.query.get(name)
|
|
78
|
+
if (value === null || value === '') return defaultValue ?? null
|
|
79
|
+
if (typeof defaultValue === 'number') {
|
|
80
|
+
const parsed = Number(value)
|
|
81
|
+
return Number.isNaN(parsed) ? defaultValue : parsed
|
|
82
|
+
}
|
|
83
|
+
return value
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Read a cookie value by name from the Cookie header. */
|
|
87
|
+
cookie(name: string): string | null {
|
|
88
|
+
if (!this._cookies) {
|
|
89
|
+
this._cookies = parseCookies(this.headers.get('cookie') ?? '')
|
|
90
|
+
}
|
|
91
|
+
return this._cookies.get(name) ?? null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Extract named string fields from a form body. With no args, returns all non-file fields. */
|
|
95
|
+
async inputs<K extends string>(...keys: K[]): Promise<Record<K, string>> {
|
|
96
|
+
const form = await this.body<FormData>()
|
|
97
|
+
const result = {} as Record<K, string>
|
|
98
|
+
if (keys.length === 0) {
|
|
99
|
+
form.forEach((value, key) => {
|
|
100
|
+
if (typeof value === 'string') (result as Record<string, string>)[key] = value
|
|
101
|
+
})
|
|
102
|
+
} else {
|
|
103
|
+
for (const key of keys) {
|
|
104
|
+
const value = form.get(key)
|
|
105
|
+
result[key] = typeof value === 'string' ? value : ''
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return result
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Extract named file fields from a form body. With no args, returns all file fields. */
|
|
112
|
+
async files<K extends string>(...keys: K[]): Promise<Record<K, File | null>> {
|
|
113
|
+
const form = await this.body<FormData>()
|
|
114
|
+
const result = {} as Record<K, File | null>
|
|
115
|
+
if (keys.length === 0) {
|
|
116
|
+
form.forEach((value, key) => {
|
|
117
|
+
if (value instanceof File) (result as Record<string, File>)[key] = value
|
|
118
|
+
})
|
|
119
|
+
} else {
|
|
120
|
+
for (const key of keys) {
|
|
121
|
+
const value = form.get(key)
|
|
122
|
+
result[key] = value instanceof File ? value : null
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Parse the request body. Automatically detects JSON, form-data, and text.
|
|
130
|
+
* The result is cached — safe to call multiple times.
|
|
131
|
+
*/
|
|
132
|
+
async body<T = unknown>(): Promise<T> {
|
|
133
|
+
if (!this._bodyParsed) {
|
|
134
|
+
const contentType = this.header('content-type') ?? ''
|
|
135
|
+
|
|
136
|
+
if (contentType.includes('application/json')) {
|
|
137
|
+
this._body = await this.request.json()
|
|
138
|
+
} else if (
|
|
139
|
+
contentType.includes('multipart/form-data') ||
|
|
140
|
+
contentType.includes('application/x-www-form-urlencoded')
|
|
141
|
+
) {
|
|
142
|
+
const formData = await this.request.formData()
|
|
143
|
+
const obj: Record<string, unknown> = {}
|
|
144
|
+
formData.forEach((value, key) => {
|
|
145
|
+
obj[key] = value
|
|
146
|
+
})
|
|
147
|
+
this._body = obj
|
|
148
|
+
} else {
|
|
149
|
+
this._body = await this.request.text()
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
this._bodyParsed = true
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return this._body as T
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
// Response helpers
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
json(data: unknown, status = 200): Response {
|
|
163
|
+
return new Response(JSON.stringify(data), {
|
|
164
|
+
status,
|
|
165
|
+
headers: { 'Content-Type': 'application/json' },
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
text(content: string, status = 200): Response {
|
|
170
|
+
return new Response(content, {
|
|
171
|
+
status,
|
|
172
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
html(content: string, status = 200): Response {
|
|
177
|
+
return new Response(content, {
|
|
178
|
+
status,
|
|
179
|
+
headers: { 'Content-Type': 'text/html' },
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
redirect(url: string, status = 302): Response {
|
|
184
|
+
return new Response(null, {
|
|
185
|
+
status,
|
|
186
|
+
headers: { Location: url },
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
empty(status = 204): Response {
|
|
191
|
+
return new Response(null, { status })
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async view(template: string, data?: Record<string, unknown>, status = 200): Promise<Response> {
|
|
195
|
+
if (!Context._viewEngine) {
|
|
196
|
+
throw new ConfigurationError('ViewEngine not configured. Register it in the container.')
|
|
197
|
+
}
|
|
198
|
+
const html = await Context._viewEngine.render(template, data)
|
|
199
|
+
return this.html(html, status)
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// Middleware state
|
|
204
|
+
// ---------------------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/** Store a value for downstream middleware / handlers. */
|
|
207
|
+
set<T>(key: string, value: T): void {
|
|
208
|
+
this._state.set(key, value)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Retrieve a value set by upstream middleware. */
|
|
212
|
+
get<T>(key: string): T
|
|
213
|
+
get<T1, T2>(k1: string, k2: string): [T1, T2]
|
|
214
|
+
get<T1, T2, T3>(k1: string, k2: string, k3: string): [T1, T2, T3]
|
|
215
|
+
get<T1, T2, T3, T4>(k1: string, k2: string, k3: string, k4: string): [T1, T2, T3, T4]
|
|
216
|
+
get(...keys: string[]): unknown {
|
|
217
|
+
if (keys.length === 1) return this._state.get(keys[0]!)
|
|
218
|
+
return keys.map(k => this._state.get(k))
|
|
219
|
+
}
|
|
220
|
+
}
|