@stravigor/core 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/README.md +45 -0
- package/package.json +83 -0
- package/src/auth/access_token.ts +122 -0
- package/src/auth/auth.ts +86 -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/broadcast/broadcast_manager.ts +411 -0
- package/src/broadcast/client.ts +302 -0
- package/src/broadcast/index.ts +58 -0
- package/src/cache/cache_manager.ts +56 -0
- package/src/cache/cache_store.ts +31 -0
- package/src/cache/helpers.ts +74 -0
- package/src/cache/http_cache.ts +109 -0
- package/src/cache/index.ts +6 -0
- package/src/cache/memory_store.ts +63 -0
- package/src/cli/bootstrap.ts +37 -0
- package/src/cli/commands/generate_api.ts +74 -0
- package/src/cli/commands/generate_key.ts +46 -0
- package/src/cli/commands/generate_models.ts +48 -0
- package/src/cli/commands/migration_compare.ts +152 -0
- package/src/cli/commands/migration_fresh.ts +123 -0
- package/src/cli/commands/migration_generate.ts +79 -0
- package/src/cli/commands/migration_rollback.ts +53 -0
- package/src/cli/commands/migration_run.ts +44 -0
- package/src/cli/commands/queue_flush.ts +35 -0
- package/src/cli/commands/queue_retry.ts +34 -0
- package/src/cli/commands/queue_work.ts +40 -0
- package/src/cli/commands/scheduler_work.ts +45 -0
- package/src/cli/strav.ts +33 -0
- package/src/config/configuration.ts +105 -0
- package/src/config/loaders/base_loader.ts +69 -0
- package/src/config/loaders/env_loader.ts +112 -0
- package/src/config/loaders/typescript_loader.ts +56 -0
- package/src/config/types.ts +8 -0
- package/src/core/application.ts +4 -0
- package/src/core/container.ts +117 -0
- package/src/core/index.ts +3 -0
- package/src/core/inject.ts +39 -0
- package/src/database/database.ts +54 -0
- package/src/database/index.ts +30 -0
- package/src/database/introspector.ts +446 -0
- package/src/database/migration/differ.ts +308 -0
- package/src/database/migration/file_generator.ts +125 -0
- package/src/database/migration/index.ts +18 -0
- package/src/database/migration/runner.ts +133 -0
- package/src/database/migration/sql_generator.ts +378 -0
- package/src/database/migration/tracker.ts +76 -0
- package/src/database/migration/types.ts +189 -0
- package/src/database/query_builder.ts +474 -0
- package/src/encryption/encryption_manager.ts +209 -0
- package/src/encryption/helpers.ts +158 -0
- package/src/encryption/index.ts +3 -0
- package/src/encryption/types.ts +6 -0
- package/src/events/emitter.ts +101 -0
- package/src/events/index.ts +2 -0
- package/src/exceptions/errors.ts +75 -0
- package/src/exceptions/exception_handler.ts +126 -0
- package/src/exceptions/helpers.ts +25 -0
- package/src/exceptions/http_exception.ts +129 -0
- package/src/exceptions/index.ts +23 -0
- package/src/exceptions/strav_error.ts +11 -0
- package/src/generators/api_generator.ts +972 -0
- package/src/generators/config.ts +87 -0
- package/src/generators/doc_generator.ts +974 -0
- package/src/generators/index.ts +11 -0
- package/src/generators/model_generator.ts +586 -0
- package/src/generators/route_generator.ts +188 -0
- package/src/generators/test_generator.ts +1666 -0
- package/src/helpers/crypto.ts +4 -0
- package/src/helpers/env.ts +50 -0
- package/src/helpers/identity.ts +12 -0
- package/src/helpers/index.ts +4 -0
- package/src/helpers/strings.ts +67 -0
- package/src/http/context.ts +215 -0
- package/src/http/cookie.ts +59 -0
- package/src/http/cors.ts +163 -0
- package/src/http/index.ts +16 -0
- package/src/http/middleware.ts +39 -0
- package/src/http/rate_limit.ts +173 -0
- package/src/http/router.ts +556 -0
- package/src/http/server.ts +79 -0
- package/src/i18n/defaults/en/validation.json +20 -0
- package/src/i18n/helpers.ts +72 -0
- package/src/i18n/i18n_manager.ts +155 -0
- package/src/i18n/index.ts +4 -0
- package/src/i18n/middleware.ts +90 -0
- package/src/i18n/translator.ts +96 -0
- package/src/i18n/types.ts +17 -0
- package/src/logger/index.ts +6 -0
- package/src/logger/logger.ts +100 -0
- package/src/logger/request_logger.ts +19 -0
- package/src/logger/sinks/console_sink.ts +24 -0
- package/src/logger/sinks/file_sink.ts +24 -0
- package/src/logger/sinks/sink.ts +36 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +19 -0
- package/src/mail/mail_manager.ts +92 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/resend_transport.ts +59 -0
- package/src/mail/transports/sendgrid_transport.ts +77 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +80 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +43 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +45 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +126 -0
- package/src/notification/types.ts +122 -0
- package/src/orm/base_model.ts +351 -0
- package/src/orm/decorators.ts +127 -0
- package/src/orm/index.ts +4 -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/queue/index.ts +11 -0
- package/src/queue/queue.ts +338 -0
- package/src/queue/worker.ts +197 -0
- package/src/scheduler/cron.ts +140 -0
- package/src/scheduler/index.ts +7 -0
- package/src/scheduler/runner.ts +116 -0
- package/src/scheduler/schedule.ts +183 -0
- package/src/scheduler/scheduler.ts +47 -0
- package/src/schema/database_representation.ts +122 -0
- package/src/schema/define_association.ts +60 -0
- package/src/schema/define_schema.ts +46 -0
- package/src/schema/field_builder.ts +155 -0
- package/src/schema/field_definition.ts +66 -0
- package/src/schema/index.ts +21 -0
- package/src/schema/naming.ts +19 -0
- package/src/schema/postgres.ts +109 -0
- package/src/schema/registry.ts +157 -0
- package/src/schema/representation_builder.ts +479 -0
- package/src/schema/type_builder.ts +107 -0
- package/src/schema/types.ts +35 -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 +81 -0
- package/src/storage/index.ts +13 -0
- package/src/storage/local_driver.ts +46 -0
- package/src/storage/s3_driver.ts +51 -0
- package/src/storage/storage.ts +43 -0
- package/src/storage/storage_manager.ts +59 -0
- package/src/storage/types.ts +42 -0
- package/src/storage/upload.ts +91 -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 +50 -0
- package/src/view/compiler.ts +185 -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 +161 -0
- package/src/view/islands/vue_plugin.ts +140 -0
- package/src/view/middleware/static.ts +35 -0
- package/src/view/tokenizer.ts +172 -0
- package/tsconfig.json +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @stravigor/core
|
|
2
|
+
|
|
3
|
+
The Strav framework for Bun. A full-stack TypeScript framework built on [Bun](https://bun.sh) with PostgreSQL.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @stravigor/core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Modules
|
|
12
|
+
|
|
13
|
+
Import what you need via subpath exports:
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { router } from '@stravigor/core/http'
|
|
17
|
+
import { defineSchema } from '@stravigor/core/schema'
|
|
18
|
+
import BaseModel from '@stravigor/core/orm/base_model'
|
|
19
|
+
import { session } from '@stravigor/core/session'
|
|
20
|
+
import { auth } from '@stravigor/core/auth'
|
|
21
|
+
import { cache } from '@stravigor/core/cache'
|
|
22
|
+
import { mail } from '@stravigor/core/mail'
|
|
23
|
+
import { broadcast } from '@stravigor/core/broadcast'
|
|
24
|
+
import { env } from '@stravigor/core/helpers/env'
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Available modules: `core`, `config`, `database`, `schema`, `orm`, `http`, `view`, `validation`, `session`, `auth`, `events`, `queue`, `policy`, `helpers`, `generators`, `logger`, `storage`, `cache`, `scheduler`, `mail`, `notification`, `broadcast`, `encryption`, `exceptions`, `i18n`, `cli`.
|
|
28
|
+
|
|
29
|
+
## CLI
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
bunx strav migration:run
|
|
33
|
+
bunx strav migration:generate
|
|
34
|
+
bunx strav generate:models
|
|
35
|
+
bunx strav generate:api
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Related Packages
|
|
39
|
+
|
|
40
|
+
- [@stravigor/ai](https://www.npmjs.com/package/@stravigor/ai) - AI module (Anthropic, OpenAI)
|
|
41
|
+
- [@stravigor/testing](https://www.npmjs.com/package/@stravigor/testing) - Testing utilities
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "The Strav framework for Bun",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["bun", "framework", "typescript", "strav"],
|
|
8
|
+
"files": ["src/", "package.json", "tsconfig.json"],
|
|
9
|
+
"exports": {
|
|
10
|
+
"./core": "./src/core/index.ts",
|
|
11
|
+
"./core/*": "./src/core/*.ts",
|
|
12
|
+
"./config": "./src/config/index.ts",
|
|
13
|
+
"./config/*": "./src/config/*.ts",
|
|
14
|
+
"./database": "./src/database/index.ts",
|
|
15
|
+
"./database/*": "./src/database/*.ts",
|
|
16
|
+
"./schema": "./src/schema/index.ts",
|
|
17
|
+
"./schema/*": "./src/schema/*.ts",
|
|
18
|
+
"./orm": "./src/orm/index.ts",
|
|
19
|
+
"./orm/*": "./src/orm/*.ts",
|
|
20
|
+
"./http": "./src/http/index.ts",
|
|
21
|
+
"./http/*": "./src/http/*.ts",
|
|
22
|
+
"./view": "./src/view/index.ts",
|
|
23
|
+
"./view/*": "./src/view/*.ts",
|
|
24
|
+
"./validation": "./src/validation/index.ts",
|
|
25
|
+
"./validation/*": "./src/validation/*.ts",
|
|
26
|
+
"./session": "./src/session/index.ts",
|
|
27
|
+
"./session/*": "./src/session/*.ts",
|
|
28
|
+
"./auth": "./src/auth/index.ts",
|
|
29
|
+
"./auth/*": "./src/auth/*.ts",
|
|
30
|
+
"./events": "./src/events/index.ts",
|
|
31
|
+
"./events/*": "./src/events/*.ts",
|
|
32
|
+
"./queue": "./src/queue/index.ts",
|
|
33
|
+
"./queue/*": "./src/queue/*.ts",
|
|
34
|
+
"./policy": "./src/policy/index.ts",
|
|
35
|
+
"./policy/*": "./src/policy/*.ts",
|
|
36
|
+
"./helpers": "./src/helpers/index.ts",
|
|
37
|
+
"./helpers/*": "./src/helpers/*.ts",
|
|
38
|
+
"./generators": "./src/generators/index.ts",
|
|
39
|
+
"./generators/*": "./src/generators/*.ts",
|
|
40
|
+
"./logger": "./src/logger/index.ts",
|
|
41
|
+
"./logger/*": "./src/logger/*.ts",
|
|
42
|
+
"./storage": "./src/storage/index.ts",
|
|
43
|
+
"./storage/*": "./src/storage/*.ts",
|
|
44
|
+
"./cache": "./src/cache/index.ts",
|
|
45
|
+
"./cache/*": "./src/cache/*.ts",
|
|
46
|
+
"./scheduler": "./src/scheduler/index.ts",
|
|
47
|
+
"./scheduler/*": "./src/scheduler/*.ts",
|
|
48
|
+
"./mail": "./src/mail/index.ts",
|
|
49
|
+
"./mail/*": "./src/mail/*.ts",
|
|
50
|
+
"./notification": "./src/notification/index.ts",
|
|
51
|
+
"./notification/*": "./src/notification/*.ts",
|
|
52
|
+
"./broadcast": "./src/broadcast/index.ts",
|
|
53
|
+
"./broadcast/*": "./src/broadcast/*.ts",
|
|
54
|
+
"./encryption": "./src/encryption/index.ts",
|
|
55
|
+
"./encryption/*": "./src/encryption/*.ts",
|
|
56
|
+
"./exceptions": "./src/exceptions/index.ts",
|
|
57
|
+
"./exceptions/*": "./src/exceptions/*.ts",
|
|
58
|
+
"./i18n": "./src/i18n/index.ts",
|
|
59
|
+
"./i18n/*": "./src/i18n/*.ts",
|
|
60
|
+
"./cli": "./src/cli/strav.ts",
|
|
61
|
+
"./cli/*": "./src/cli/*.ts"
|
|
62
|
+
},
|
|
63
|
+
"bin": {
|
|
64
|
+
"strav": "./src/cli/strav.ts"
|
|
65
|
+
},
|
|
66
|
+
"dependencies": {
|
|
67
|
+
"@types/luxon": "^3.7.1",
|
|
68
|
+
"chalk": "^5.6.2",
|
|
69
|
+
"commander": "^14.0.3",
|
|
70
|
+
"luxon": "^3.7.2",
|
|
71
|
+
"pino": "^10.3.1",
|
|
72
|
+
"pino-pretty": "^13.1.3",
|
|
73
|
+
"nodemailer": "^6.10.0",
|
|
74
|
+
"@types/nodemailer": "^6.4.0",
|
|
75
|
+
"juice": "^11.0.0",
|
|
76
|
+
"reflect-metadata": "^0.2.2",
|
|
77
|
+
"@vue/compiler-sfc": "^3.5.28"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"test": "bun test tests/",
|
|
81
|
+
"typecheck": "tsc --noEmit"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -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,86 @@
|
|
|
1
|
+
import { inject } from '../core/inject.ts'
|
|
2
|
+
import { ConfigurationError } from '../exceptions/errors.ts'
|
|
3
|
+
import Configuration from '../config/configuration.ts'
|
|
4
|
+
import Database from '../database/database.ts'
|
|
5
|
+
|
|
6
|
+
// Re-export helpers that were originally defined here
|
|
7
|
+
export { extractUserId } from '../helpers/identity.ts'
|
|
8
|
+
export { randomHex } from '../helpers/crypto.ts'
|
|
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) throw new ConfigurationError('Auth not configured. Resolve Auth through the container first.')
|
|
57
|
+
return Auth._db
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
static get config(): AuthConfig {
|
|
61
|
+
return Auth._config
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Load a user by ID using the registered resolver. */
|
|
65
|
+
static async resolveUser(id: string | number): Promise<unknown> {
|
|
66
|
+
if (!Auth._resolver) {
|
|
67
|
+
throw new ConfigurationError('Auth resolver not configured. Call Auth.useResolver() first.')
|
|
68
|
+
}
|
|
69
|
+
return Auth._resolver(id)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Create the internal access_tokens table if it doesn't exist. */
|
|
73
|
+
static async ensureTables(): Promise<void> {
|
|
74
|
+
await Auth.db.sql`
|
|
75
|
+
CREATE TABLE IF NOT EXISTS "_strav_access_tokens" (
|
|
76
|
+
"id" SERIAL PRIMARY KEY,
|
|
77
|
+
"user_id" VARCHAR(255) NOT NULL,
|
|
78
|
+
"name" VARCHAR(255) NOT NULL,
|
|
79
|
+
"token" VARCHAR(64) NOT NULL UNIQUE,
|
|
80
|
+
"last_used_at" TIMESTAMPTZ,
|
|
81
|
+
"expires_at" TIMESTAMPTZ,
|
|
82
|
+
"created_at" TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
83
|
+
)
|
|
84
|
+
`
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -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
|
+
}
|