@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.
Files changed (50) hide show
  1. package/.dockerignore +8 -0
  2. package/.env.example +19 -0
  3. package/ARCHITECTURE.md +14 -0
  4. package/CHANGELOG.md +14 -0
  5. package/Dockerfile +25 -0
  6. package/README.md +112 -0
  7. package/WHITEPAPER.md +20 -0
  8. package/dist/index.d.ts +26 -0
  9. package/dist/index.js +1121 -0
  10. package/docs/EXTENDING.md +99 -0
  11. package/docs/PASSKEYS.md +78 -0
  12. package/locales/en.json +30 -0
  13. package/locales/zh-TW.json +30 -0
  14. package/package.json +35 -0
  15. package/src/Application/DTOs/MemberDTO.ts +34 -0
  16. package/src/Application/Mail/ForgotPasswordMail.ts +42 -0
  17. package/src/Application/Mail/MemberLevelChangedMail.ts +41 -0
  18. package/src/Application/Mail/WelcomeMail.ts +45 -0
  19. package/src/Application/Services/PasskeysService.ts +198 -0
  20. package/src/Application/UseCases/ForgotPassword.ts +34 -0
  21. package/src/Application/UseCases/LoginMember.ts +64 -0
  22. package/src/Application/UseCases/RegisterMember.ts +65 -0
  23. package/src/Application/UseCases/ResetPassword.ts +30 -0
  24. package/src/Application/UseCases/UpdateMemberLevel.ts +47 -0
  25. package/src/Application/UseCases/UpdateSettings.ts +81 -0
  26. package/src/Application/UseCases/VerifyEmail.ts +23 -0
  27. package/src/Domain/Contracts/IMemberPasskeyRepository.ts +8 -0
  28. package/src/Domain/Contracts/IMemberRepository.ts +11 -0
  29. package/src/Domain/Entities/Member.ts +219 -0
  30. package/src/Domain/Entities/MemberPasskey.ts +97 -0
  31. package/src/Infrastructure/Auth/SentinelMemberProvider.ts +52 -0
  32. package/src/Infrastructure/Persistence/AtlasMemberPasskeyRepository.ts +63 -0
  33. package/src/Infrastructure/Persistence/AtlasMemberRepository.ts +91 -0
  34. package/src/Infrastructure/Persistence/Migrations/20250101_create_members_table.ts +30 -0
  35. package/src/Infrastructure/Persistence/Migrations/20250102_create_member_passkeys_table.ts +25 -0
  36. package/src/Interface/Http/Controllers/PasskeyController.ts +98 -0
  37. package/src/Interface/Http/Middleware/VerifySingleDevice.ts +51 -0
  38. package/src/index.ts +234 -0
  39. package/src/manifest.json +15 -0
  40. package/tests/PasskeysService.test.ts +113 -0
  41. package/tests/email-integration.test.ts +161 -0
  42. package/tests/grand-review.ts +176 -0
  43. package/tests/integration.test.ts +75 -0
  44. package/tests/member.test.ts +47 -0
  45. package/tests/unit.test.ts +7 -0
  46. package/tests/update-settings.test.ts +101 -0
  47. package/tsconfig.json +26 -0
  48. package/views/emails/level_changed.html +35 -0
  49. package/views/emails/reset_password.html +34 -0
  50. 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
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "membership",
3
+ "id": "membership",
4
+ "version": "0.1.0",
5
+ "description": "A Gravito Satellite",
6
+ "capabilities": [
7
+ "create-membership"
8
+ ],
9
+ "requirements": [
10
+ "cache"
11
+ ],
12
+ "hooks": [
13
+ "membership:created"
14
+ ]
15
+ }