@gravito/satellite-membership 0.1.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.
- package/.dockerignore +8 -0
- package/.env.example +19 -0
- package/ARCHITECTURE.md +14 -0
- package/CHANGELOG.md +14 -0
- package/Dockerfile +25 -0
- package/README.md +112 -0
- package/WHITEPAPER.md +20 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +1121 -0
- package/docs/EXTENDING.md +99 -0
- package/docs/PASSKEYS.md +78 -0
- package/locales/en.json +30 -0
- package/locales/zh-TW.json +30 -0
- package/package.json +35 -0
- package/src/Application/DTOs/MemberDTO.ts +34 -0
- package/src/Application/Mail/ForgotPasswordMail.ts +42 -0
- package/src/Application/Mail/MemberLevelChangedMail.ts +41 -0
- package/src/Application/Mail/WelcomeMail.ts +45 -0
- package/src/Application/Services/PasskeysService.ts +198 -0
- package/src/Application/UseCases/ForgotPassword.ts +34 -0
- package/src/Application/UseCases/LoginMember.ts +64 -0
- package/src/Application/UseCases/RegisterMember.ts +65 -0
- package/src/Application/UseCases/ResetPassword.ts +30 -0
- package/src/Application/UseCases/UpdateMemberLevel.ts +47 -0
- package/src/Application/UseCases/UpdateSettings.ts +81 -0
- package/src/Application/UseCases/VerifyEmail.ts +23 -0
- package/src/Domain/Contracts/IMemberPasskeyRepository.ts +8 -0
- package/src/Domain/Contracts/IMemberRepository.ts +11 -0
- package/src/Domain/Entities/Member.ts +219 -0
- package/src/Domain/Entities/MemberPasskey.ts +97 -0
- package/src/Infrastructure/Auth/SentinelMemberProvider.ts +52 -0
- package/src/Infrastructure/Persistence/AtlasMemberPasskeyRepository.ts +63 -0
- package/src/Infrastructure/Persistence/AtlasMemberRepository.ts +91 -0
- package/src/Infrastructure/Persistence/Migrations/20250101_create_members_table.ts +30 -0
- package/src/Infrastructure/Persistence/Migrations/20250102_create_member_passkeys_table.ts +25 -0
- package/src/Interface/Http/Controllers/PasskeyController.ts +98 -0
- package/src/Interface/Http/Middleware/VerifySingleDevice.ts +51 -0
- package/src/index.ts +234 -0
- package/src/manifest.json +15 -0
- package/tests/PasskeysService.test.ts +113 -0
- package/tests/email-integration.test.ts +161 -0
- package/tests/grand-review.ts +176 -0
- package/tests/integration.test.ts +75 -0
- package/tests/member.test.ts +47 -0
- package/tests/unit.test.ts +7 -0
- package/tests/update-settings.test.ts +101 -0
- package/tsconfig.json +26 -0
- package/views/emails/level_changed.html +35 -0
- package/views/emails/reset_password.html +34 -0
- package/views/emails/welcome.html +31 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DB } from '@gravito/atlas'
|
|
2
|
+
import type { IMemberPasskeyRepository } from '../../Domain/Contracts/IMemberPasskeyRepository'
|
|
3
|
+
import { MemberPasskey } from '../../Domain/Entities/MemberPasskey'
|
|
4
|
+
|
|
5
|
+
export class AtlasMemberPasskeyRepository implements IMemberPasskeyRepository {
|
|
6
|
+
private table = 'member_passkeys'
|
|
7
|
+
|
|
8
|
+
async save(entity: MemberPasskey): Promise<void> {
|
|
9
|
+
const record = entity.toRecord()
|
|
10
|
+
const exists = await this.exists(entity.id)
|
|
11
|
+
if (exists) {
|
|
12
|
+
await DB.table(this.table).where('id', entity.id).update(record)
|
|
13
|
+
} else {
|
|
14
|
+
await DB.table(this.table).insert(record)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async findById(id: string): Promise<MemberPasskey | null> {
|
|
19
|
+
const row = await DB.table(this.table).where('id', id).first()
|
|
20
|
+
return row ? this.map(row) : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async findAll(): Promise<MemberPasskey[]> {
|
|
24
|
+
const rows = await DB.table(this.table).get()
|
|
25
|
+
return rows.map((row: any) => this.map(row))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async delete(id: string): Promise<void> {
|
|
29
|
+
await DB.table(this.table).where('id', id).delete()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async exists(id: string): Promise<boolean> {
|
|
33
|
+
const count = await DB.table(this.table).where('id', id).count()
|
|
34
|
+
return count > 0
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async findByMemberId(memberId: string): Promise<MemberPasskey[]> {
|
|
38
|
+
const rows = await DB.table(this.table).where('member_id', memberId).get()
|
|
39
|
+
return rows.map((row: any) => this.map(row))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async findByCredentialId(credentialId: string): Promise<MemberPasskey | null> {
|
|
43
|
+
const row = await DB.table(this.table).where('credential_id', credentialId).first()
|
|
44
|
+
return row ? this.map(row) : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async deleteByCredentialId(credentialId: string): Promise<void> {
|
|
48
|
+
await DB.table(this.table).where('credential_id', credentialId).delete()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private map(row: any): MemberPasskey {
|
|
52
|
+
return MemberPasskey.reconstitute(row.id, {
|
|
53
|
+
memberId: row.member_id,
|
|
54
|
+
credentialId: row.credential_id,
|
|
55
|
+
publicKey: row.public_key,
|
|
56
|
+
counter: Number(row.counter ?? 0),
|
|
57
|
+
transports: row.transports ? JSON.parse(row.transports) : undefined,
|
|
58
|
+
displayName: row.display_name || undefined,
|
|
59
|
+
createdAt: new Date(row.created_at),
|
|
60
|
+
updatedAt: row.updated_at ? new Date(row.updated_at) : new Date(row.created_at),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { DB } from '@gravito/atlas'
|
|
2
|
+
import type { IMemberRepository } from '../../Domain/Contracts/IMemberRepository'
|
|
3
|
+
import { Member } from '../../Domain/Entities/Member'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Atlas Member Repository Implementation
|
|
7
|
+
*/
|
|
8
|
+
export class AtlasMemberRepository implements IMemberRepository {
|
|
9
|
+
private table = 'members'
|
|
10
|
+
|
|
11
|
+
async save(member: Member): Promise<void> {
|
|
12
|
+
const data = {
|
|
13
|
+
id: member.id,
|
|
14
|
+
name: member.name,
|
|
15
|
+
email: member.email,
|
|
16
|
+
password_hash: member.passwordHash,
|
|
17
|
+
status: member.status,
|
|
18
|
+
roles: JSON.stringify(member.roles),
|
|
19
|
+
verification_token: member.verificationToken || null,
|
|
20
|
+
email_verified_at: member.emailVerifiedAt || null,
|
|
21
|
+
password_reset_token: member.passwordResetToken || null,
|
|
22
|
+
password_reset_expires_at: member.passwordResetExpiresAt || null,
|
|
23
|
+
current_session_id: member.currentSessionId || null,
|
|
24
|
+
remember_token: member.rememberToken || null,
|
|
25
|
+
created_at: member.createdAt,
|
|
26
|
+
metadata: JSON.stringify(member.metadata),
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const exists = await this.exists(member.id)
|
|
30
|
+
if (exists) {
|
|
31
|
+
await DB.table(this.table).where('id', member.id).update(data)
|
|
32
|
+
} else {
|
|
33
|
+
await DB.table(this.table).insert(data)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async findByEmail(email: string): Promise<Member | null> {
|
|
38
|
+
const row = await DB.table(this.table).where('email', email).first()
|
|
39
|
+
return row ? this.mapToDomain(row) : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async findByVerificationToken(token: string): Promise<Member | null> {
|
|
43
|
+
const row = await DB.table(this.table).where('verification_token', token).first()
|
|
44
|
+
return row ? this.mapToDomain(row) : null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async findByResetToken(token: string): Promise<Member | null> {
|
|
48
|
+
const row = await DB.table(this.table).where('password_reset_token', token).first()
|
|
49
|
+
return row ? this.mapToDomain(row) : null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async findById(id: string): Promise<Member | null> {
|
|
53
|
+
const row = await DB.table(this.table).where('id', id).first()
|
|
54
|
+
return row ? this.mapToDomain(row) : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async findAll(): Promise<Member[]> {
|
|
58
|
+
const rows = await DB.table(this.table).get()
|
|
59
|
+
return rows.map((row: any) => this.mapToDomain(row))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async delete(id: string): Promise<void> {
|
|
63
|
+
await DB.table(this.table).where('id', id).delete()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async exists(id: string): Promise<boolean> {
|
|
67
|
+
const count = await DB.table(this.table).where('id', id).count()
|
|
68
|
+
return count > 0
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
private mapToDomain(row: any): Member {
|
|
72
|
+
return Member.reconstitute(row.id, {
|
|
73
|
+
name: row.name,
|
|
74
|
+
email: row.email,
|
|
75
|
+
passwordHash: row.password_hash,
|
|
76
|
+
status: row.status,
|
|
77
|
+
roles: row.roles ? JSON.parse(row.roles) : ['member'],
|
|
78
|
+
verificationToken: row.verification_token,
|
|
79
|
+
emailVerifiedAt: row.email_verified_at ? new Date(row.email_verified_at) : undefined,
|
|
80
|
+
passwordResetToken: row.password_reset_token,
|
|
81
|
+
passwordResetExpiresAt: row.password_reset_expires_at
|
|
82
|
+
? new Date(row.password_reset_expires_at)
|
|
83
|
+
: undefined,
|
|
84
|
+
currentSessionId: row.current_session_id,
|
|
85
|
+
rememberToken: row.remember_token,
|
|
86
|
+
createdAt: new Date(row.created_at),
|
|
87
|
+
updatedAt: new Date(row.updated_at || row.created_at),
|
|
88
|
+
metadata: row.metadata ? JSON.parse(row.metadata) : {},
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type Blueprint, Schema } from '@gravito/atlas'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration to create the members table with full features
|
|
5
|
+
*/
|
|
6
|
+
export default {
|
|
7
|
+
async up() {
|
|
8
|
+
await Schema.create('members', (table: Blueprint) => {
|
|
9
|
+
table.string('id').primary()
|
|
10
|
+
table.string('name')
|
|
11
|
+
table.string('email').unique()
|
|
12
|
+
table.string('password_hash')
|
|
13
|
+
table.string('status').default('pending')
|
|
14
|
+
table.text('roles').default('["member"]')
|
|
15
|
+
table.string('verification_token').nullable()
|
|
16
|
+
table.timestamp('email_verified_at').nullable()
|
|
17
|
+
table.string('password_reset_token').nullable()
|
|
18
|
+
table.timestamp('password_reset_expires_at').nullable()
|
|
19
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
20
|
+
table.timestamp('updated_at').nullable()
|
|
21
|
+
table.string('current_session_id').nullable()
|
|
22
|
+
table.string('remember_token').nullable()
|
|
23
|
+
table.text('metadata').nullable()
|
|
24
|
+
})
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
async down() {
|
|
28
|
+
await Schema.dropIfExists('members')
|
|
29
|
+
},
|
|
30
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type Blueprint, Schema } from '@gravito/atlas'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Migration to create the member_passkeys table for storing WebAuthn credentials
|
|
5
|
+
*/
|
|
6
|
+
export default {
|
|
7
|
+
async up() {
|
|
8
|
+
await Schema.create('member_passkeys', (table: Blueprint) => {
|
|
9
|
+
table.string('id').primary()
|
|
10
|
+
table.string('member_id')
|
|
11
|
+
table.foreign('member_id').references('id').on('members').onDelete('cascade')
|
|
12
|
+
table.string('credential_id').unique()
|
|
13
|
+
table.text('public_key')
|
|
14
|
+
table.bigInteger('counter').default(0)
|
|
15
|
+
table.text('transports').nullable()
|
|
16
|
+
table.string('display_name').nullable()
|
|
17
|
+
table.timestamp('created_at').default('CURRENT_TIMESTAMP')
|
|
18
|
+
table.timestamp('updated_at').nullable()
|
|
19
|
+
})
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
async down() {
|
|
23
|
+
await Schema.dropIfExists('member_passkeys')
|
|
24
|
+
},
|
|
25
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { GravitoContext } from '@gravito/core'
|
|
2
|
+
import { AuthenticationException } from '@gravito/core'
|
|
3
|
+
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server'
|
|
4
|
+
import type { PasskeysService } from '../../../Application/Services/PasskeysService'
|
|
5
|
+
import type { IMemberRepository } from '../../../Domain/Contracts/IMemberRepository'
|
|
6
|
+
import type { Member } from '../../../Domain/Entities/Member'
|
|
7
|
+
|
|
8
|
+
type SessionLike = {
|
|
9
|
+
get(key: string): string | null | undefined
|
|
10
|
+
put(key: string, value: unknown): void
|
|
11
|
+
forget?(key: string): void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class PasskeyController {
|
|
15
|
+
constructor(
|
|
16
|
+
private passkeys: PasskeysService,
|
|
17
|
+
private members: IMemberRepository
|
|
18
|
+
) {}
|
|
19
|
+
|
|
20
|
+
async registrationOptions(c: GravitoContext) {
|
|
21
|
+
const auth = c.get('auth')
|
|
22
|
+
if (!auth) {
|
|
23
|
+
throw new AuthenticationException('Auth manager is unavailable.')
|
|
24
|
+
}
|
|
25
|
+
const member = (await auth.authenticate()) as Member
|
|
26
|
+
const session = this.resolveSession(c)
|
|
27
|
+
const options = await this.passkeys.generateRegistrationOptions(member, session)
|
|
28
|
+
return c.json(options)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async verifyRegistration(c: GravitoContext) {
|
|
32
|
+
const auth = c.get('auth')
|
|
33
|
+
if (!auth) {
|
|
34
|
+
throw new AuthenticationException('Auth manager is unavailable.')
|
|
35
|
+
}
|
|
36
|
+
const member = (await auth.authenticate()) as Member
|
|
37
|
+
const session = this.resolveSession(c)
|
|
38
|
+
const body = (await c.req.json()) as {
|
|
39
|
+
credential: RegistrationResponseJSON
|
|
40
|
+
displayName?: string
|
|
41
|
+
}
|
|
42
|
+
await this.passkeys.verifyRegistrationResponse(
|
|
43
|
+
member,
|
|
44
|
+
body.credential,
|
|
45
|
+
session,
|
|
46
|
+
body.displayName
|
|
47
|
+
)
|
|
48
|
+
return c.json({ success: true })
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async loginOptions(c: GravitoContext) {
|
|
52
|
+
const session = this.resolveSession(c)
|
|
53
|
+
const body = (await c.req.json()) as { email?: string }
|
|
54
|
+
if (!body?.email) {
|
|
55
|
+
return c.json({ error: 'Email is required' }, 400)
|
|
56
|
+
}
|
|
57
|
+
const member = await this.members.findByEmail(body.email)
|
|
58
|
+
if (!member) {
|
|
59
|
+
return c.json({ error: 'Member not found' }, 404)
|
|
60
|
+
}
|
|
61
|
+
const options = await this.passkeys.generateAuthenticationOptions(member, session)
|
|
62
|
+
return c.json(options)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async verifyAuthentication(c: GravitoContext) {
|
|
66
|
+
const session = this.resolveSession(c)
|
|
67
|
+
const body = (await c.req.json()) as {
|
|
68
|
+
email: string
|
|
69
|
+
assertion: AuthenticationResponseJSON
|
|
70
|
+
}
|
|
71
|
+
if (!body?.email || !body.assertion) {
|
|
72
|
+
return c.json({ error: 'Email and assertion are required' }, 400)
|
|
73
|
+
}
|
|
74
|
+
const member = await this.members.findByEmail(body.email)
|
|
75
|
+
if (!member) {
|
|
76
|
+
return c.json({ error: 'Member not found' }, 404)
|
|
77
|
+
}
|
|
78
|
+
await this.passkeys.verifyAuthenticationResponse(member, body.assertion, session)
|
|
79
|
+
const auth = c.get('auth')
|
|
80
|
+
if (!auth) {
|
|
81
|
+
throw new AuthenticationException('Auth manager is unavailable.')
|
|
82
|
+
}
|
|
83
|
+
await auth.login(member)
|
|
84
|
+
return c.json({ success: true })
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private resolveSession(c: GravitoContext): SessionLike {
|
|
88
|
+
const session = c.get('session') as SessionLike | undefined
|
|
89
|
+
if (session) {
|
|
90
|
+
return session
|
|
91
|
+
}
|
|
92
|
+
const fallback = (c.req as any).session
|
|
93
|
+
if (fallback) {
|
|
94
|
+
return fallback
|
|
95
|
+
}
|
|
96
|
+
throw new AuthenticationException('Session storage is required for Passkeys.')
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { GravitoContext } from '@gravito/core'
|
|
2
|
+
import type { IMemberRepository } from '../../../Domain/Contracts/IMemberRepository'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Verify Single Device Middleware
|
|
6
|
+
*
|
|
7
|
+
* Ensures that the current session matches the one stored in the database.
|
|
8
|
+
* If a new login occurs on another device, the old session becomes invalid.
|
|
9
|
+
*/
|
|
10
|
+
export const verifySingleDevice = async (c: GravitoContext, next: () => Promise<void>) => {
|
|
11
|
+
const core = c.get('core' as any) as any
|
|
12
|
+
|
|
13
|
+
// 1. Check if the feature is enabled
|
|
14
|
+
const isEnabled = core.config.get('membership.auth.single_device', false)
|
|
15
|
+
if (!isEnabled) {
|
|
16
|
+
return await next()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 2. Get auth and session services
|
|
20
|
+
const auth = core.container.make('auth')
|
|
21
|
+
const session = core.container.make('session')
|
|
22
|
+
const repo = core.container.make('membership.repo') as IMemberRepository
|
|
23
|
+
|
|
24
|
+
if (!auth || !session || !repo) {
|
|
25
|
+
return await next()
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// 3. Check if user is logged in
|
|
29
|
+
const user = await auth.guard('web').user()
|
|
30
|
+
if (!user) {
|
|
31
|
+
return await next()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 4. Compare current session ID with the one in DB
|
|
35
|
+
const member = await repo.findById(user.id)
|
|
36
|
+
const currentSessionId = session.id()
|
|
37
|
+
|
|
38
|
+
if (member?.currentSessionId && member.currentSessionId !== currentSessionId) {
|
|
39
|
+
// Session mismatch! Logout the current session.
|
|
40
|
+
await auth.guard('web').logout()
|
|
41
|
+
|
|
42
|
+
// Redirect or throw error
|
|
43
|
+
const i18n = core.container.make('i18n')
|
|
44
|
+
throw new Error(
|
|
45
|
+
i18n?.t('membership.errors.session_expired_other_device') ||
|
|
46
|
+
'Session expired. Logged in from another device.'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await next()
|
|
51
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import { type Container, ServiceProvider } from '@gravito/core'
|
|
5
|
+
import { auth } from '@gravito/sentinel'
|
|
6
|
+
import { ForgotPasswordMail } from './Application/Mail/ForgotPasswordMail'
|
|
7
|
+
import { MemberLevelChangedMail } from './Application/Mail/MemberLevelChangedMail'
|
|
8
|
+
import { WelcomeMail } from './Application/Mail/WelcomeMail'
|
|
9
|
+
import type { PasskeysConfig } from './Application/Services/PasskeysService'
|
|
10
|
+
import { PasskeysService } from './Application/Services/PasskeysService'
|
|
11
|
+
import { ForgotPassword } from './Application/UseCases/ForgotPassword'
|
|
12
|
+
import { LoginMember } from './Application/UseCases/LoginMember'
|
|
13
|
+
import { RegisterMember } from './Application/UseCases/RegisterMember'
|
|
14
|
+
import { ResetPassword } from './Application/UseCases/ResetPassword'
|
|
15
|
+
import { UpdateMemberLevel } from './Application/UseCases/UpdateMemberLevel'
|
|
16
|
+
import { UpdateSettings } from './Application/UseCases/UpdateSettings'
|
|
17
|
+
import { VerifyEmail } from './Application/UseCases/VerifyEmail'
|
|
18
|
+
import { SentinelMemberProvider } from './Infrastructure/Auth/SentinelMemberProvider'
|
|
19
|
+
import { AtlasMemberPasskeyRepository } from './Infrastructure/Persistence/AtlasMemberPasskeyRepository'
|
|
20
|
+
import { AtlasMemberRepository } from './Infrastructure/Persistence/AtlasMemberRepository'
|
|
21
|
+
import { PasskeyController } from './Interface/Http/Controllers/PasskeyController'
|
|
22
|
+
|
|
23
|
+
const __dirname = fileURLToPath(new URL('.', import.meta.url))
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Membership Satellite Service Provider
|
|
27
|
+
*
|
|
28
|
+
* Handles the registration and initialization of membership services.
|
|
29
|
+
* Optimized for Bun runtime with full i18n support.
|
|
30
|
+
*/
|
|
31
|
+
export class MembershipServiceProvider extends ServiceProvider {
|
|
32
|
+
/**
|
|
33
|
+
* Register bindings in the container
|
|
34
|
+
*/
|
|
35
|
+
register(container: Container): void {
|
|
36
|
+
if (!container.has('cache')) {
|
|
37
|
+
const cacheFromServices = this.core?.services.get('cache')
|
|
38
|
+
if (cacheFromServices) {
|
|
39
|
+
container.instance('cache', cacheFromServices)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Bind Repository
|
|
44
|
+
container.singleton('membership.repo', () => new AtlasMemberRepository())
|
|
45
|
+
|
|
46
|
+
// Bind Sentinel Auth Provider (Dogfooding Sentinel)
|
|
47
|
+
container.singleton(
|
|
48
|
+
'auth.member_provider',
|
|
49
|
+
() => new SentinelMemberProvider(container.make('membership.repo'))
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// Bind UseCases
|
|
53
|
+
container.singleton('membership.register', () => {
|
|
54
|
+
return new RegisterMember(container.make('membership.repo'), this.core!)
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
container.singleton('membership.login', () => {
|
|
58
|
+
return new LoginMember(container.make('membership.repo'), this.core!)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
container.singleton('membership.forgot-password', () => {
|
|
62
|
+
return new ForgotPassword(container.make('membership.repo'), this.core!)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
container.singleton('membership.reset-password', () => {
|
|
66
|
+
return new ResetPassword(container.make('membership.repo'), this.core!)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
container.singleton('membership.verify-email', () => {
|
|
70
|
+
return new VerifyEmail(container.make('membership.repo'))
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
container.singleton('membership.update-settings', () => {
|
|
74
|
+
return new UpdateSettings(container.make('membership.repo'), this.core!)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
container.singleton('membership.update-level', () => {
|
|
78
|
+
return new UpdateMemberLevel(container.make('membership.repo'), this.core!)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
container.singleton('membership.passkeys.repo', () => new AtlasMemberPasskeyRepository())
|
|
82
|
+
|
|
83
|
+
container.singleton('membership.passkeys.service', () => {
|
|
84
|
+
return new PasskeysService(
|
|
85
|
+
container.make('membership.passkeys.repo'),
|
|
86
|
+
this.buildPasskeysConfig()
|
|
87
|
+
)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
container.singleton('membership.passkeys.controller', () => {
|
|
91
|
+
return new PasskeyController(
|
|
92
|
+
container.make('membership.passkeys.service'),
|
|
93
|
+
container.make('membership.repo')
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Expose migration path using Bun-native __dirname
|
|
100
|
+
*/
|
|
101
|
+
getMigrationsPath(): string {
|
|
102
|
+
return `${__dirname}/Infrastructure/Persistence/Migrations`
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Boot the satellite
|
|
107
|
+
* Loads local translations into the global i18n system.
|
|
108
|
+
*/
|
|
109
|
+
override async boot(): Promise<void> {
|
|
110
|
+
const logger = this.core?.logger
|
|
111
|
+
const i18n = this.core?.container.make<any>('i18n')
|
|
112
|
+
|
|
113
|
+
if (i18n) {
|
|
114
|
+
try {
|
|
115
|
+
// Standard Node.js compatible JSON loading
|
|
116
|
+
const en = JSON.parse(readFileSync(join(__dirname, '../locales/en.json'), 'utf-8'))
|
|
117
|
+
const zhTW = JSON.parse(readFileSync(join(__dirname, '../locales/zh-TW.json'), 'utf-8'))
|
|
118
|
+
|
|
119
|
+
i18n.addResource('en', 'membership', en)
|
|
120
|
+
i18n.addResource('zh-TW', 'membership', zhTW)
|
|
121
|
+
} catch (_err) {
|
|
122
|
+
logger?.warn('[Membership] Failed to load localizations')
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Register Hooks
|
|
127
|
+
if (this.core) {
|
|
128
|
+
this.core.hooks.addAction(
|
|
129
|
+
'membership:send-verification',
|
|
130
|
+
async (data: { email: string; token: string }) => {
|
|
131
|
+
try {
|
|
132
|
+
const mail = this.core?.container.make<any>('mail')
|
|
133
|
+
if (mail) {
|
|
134
|
+
await mail.queue(new WelcomeMail(data.email, data.token))
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
this.core?.logger.error('[Membership] Failed to send verification email', err)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
this.core.hooks.addAction(
|
|
143
|
+
'membership:send-reset-password',
|
|
144
|
+
async (data: { email: string; token: string }) => {
|
|
145
|
+
try {
|
|
146
|
+
const mail = this.core?.container.make<any>('mail')
|
|
147
|
+
if (mail) {
|
|
148
|
+
await mail.queue(new ForgotPasswordMail(data.email, data.token))
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
this.core?.logger.error('[Membership] Failed to send reset password email', err)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
this.core.hooks.addAction(
|
|
157
|
+
'membership:level-changed',
|
|
158
|
+
async (data: { email: string; oldLevel: string; newLevel: string }) => {
|
|
159
|
+
try {
|
|
160
|
+
const mail = this.core?.container.make<any>('mail')
|
|
161
|
+
if (mail) {
|
|
162
|
+
await mail.queue(new MemberLevelChangedMail(data.email, data.oldLevel, data.newLevel))
|
|
163
|
+
}
|
|
164
|
+
} catch (err) {
|
|
165
|
+
this.core?.logger.error('[Membership] Failed to send level change email', err)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (this.core) {
|
|
172
|
+
const passkeyController = this.core.container.make(
|
|
173
|
+
'membership.passkeys.controller'
|
|
174
|
+
) as PasskeyController
|
|
175
|
+
const passkeyRoutes = this.core.router.prefix('/api/membership/passkeys')
|
|
176
|
+
passkeyRoutes.post('/login/options', (c) => passkeyController.loginOptions(c))
|
|
177
|
+
passkeyRoutes.post('/login/verify', (c) => passkeyController.verifyAuthentication(c))
|
|
178
|
+
|
|
179
|
+
const protectedRoutes = passkeyRoutes.middleware(auth())
|
|
180
|
+
protectedRoutes.post('/register/options', (c) => passkeyController.registrationOptions(c))
|
|
181
|
+
protectedRoutes.post('/register/verify', (c) => passkeyController.verifyRegistration(c))
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const startMsg =
|
|
185
|
+
i18n?.t('membership.notifications.operational') || '🛰️ Satellite Membership is operational'
|
|
186
|
+
logger?.info(startMsg)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
private buildPasskeysConfig(): PasskeysConfig {
|
|
190
|
+
const config = this.core?.config
|
|
191
|
+
|
|
192
|
+
const readString = (key: string): string | undefined => {
|
|
193
|
+
const value = config?.get(key)
|
|
194
|
+
return typeof value === 'string' ? value : undefined
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const origin =
|
|
198
|
+
readString('membership.passkeys.origin') ??
|
|
199
|
+
readString('APP_URL') ??
|
|
200
|
+
process.env.APP_URL ??
|
|
201
|
+
'http://localhost:3000'
|
|
202
|
+
|
|
203
|
+
const rpID =
|
|
204
|
+
readString('membership.passkeys.rp_id') ??
|
|
205
|
+
(() => {
|
|
206
|
+
try {
|
|
207
|
+
return new URL(origin).hostname
|
|
208
|
+
} catch {
|
|
209
|
+
return origin
|
|
210
|
+
}
|
|
211
|
+
})()
|
|
212
|
+
|
|
213
|
+
const rpName = readString('membership.passkeys.name') ?? 'Gravito Membership'
|
|
214
|
+
const timeoutValue = config?.get('membership.passkeys.timeout')
|
|
215
|
+
const timeout =
|
|
216
|
+
typeof timeoutValue === 'number'
|
|
217
|
+
? timeoutValue
|
|
218
|
+
: typeof timeoutValue === 'string'
|
|
219
|
+
? Number(timeoutValue)
|
|
220
|
+
: 60000
|
|
221
|
+
|
|
222
|
+
const userVerification = readString('membership.passkeys.user_verification') ?? 'preferred'
|
|
223
|
+
const attestation = readString('membership.passkeys.attestation') ?? 'none'
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
rpName,
|
|
227
|
+
rpID,
|
|
228
|
+
origin,
|
|
229
|
+
timeout,
|
|
230
|
+
userVerification: userVerification as PasskeysConfig['userVerification'],
|
|
231
|
+
attestationType: attestation as PasskeysConfig['attestationType'],
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|