@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
package/dist/index.js ADDED
@@ -0,0 +1,1121 @@
1
+ // src/index.ts
2
+ import { readFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { ServiceProvider } from "@gravito/core";
6
+ import { auth } from "@gravito/sentinel";
7
+
8
+ // src/Application/Mail/ForgotPasswordMail.ts
9
+ import { app } from "@gravito/core";
10
+ import { Mailable } from "@gravito/signal";
11
+ var ForgotPasswordMail = class extends Mailable {
12
+ constructor(email, token) {
13
+ super();
14
+ this.email = email;
15
+ this.token = token;
16
+ }
17
+ build() {
18
+ const branding = {
19
+ name: "Gravito App",
20
+ color: "#f43f5e"
21
+ };
22
+ let baseUrl = "http://localhost:3000";
23
+ try {
24
+ const core = app();
25
+ if (core) {
26
+ branding.name = core.config.get("membership.branding.name", branding.name);
27
+ branding.color = core.config.get("membership.branding.primary_color", branding.color);
28
+ baseUrl = core.config.get("app.url", baseUrl);
29
+ }
30
+ } catch (_e) {
31
+ }
32
+ return this.to(this.email).subject(this.t("membership.emails.reset_password_subject")).view("emails/reset_password", {
33
+ resetUrl: `${baseUrl}/reset-password?token=${this.token}`,
34
+ branding,
35
+ currentYear: (/* @__PURE__ */ new Date()).getFullYear(),
36
+ lang: {
37
+ reset_password_title: this.t("membership.emails.reset_password_title"),
38
+ reset_password_body: this.t("membership.emails.reset_password_body"),
39
+ reset_password_button: this.t("membership.emails.reset_password_button"),
40
+ reset_password_warning: this.t("membership.emails.reset_password_warning")
41
+ }
42
+ });
43
+ }
44
+ };
45
+
46
+ // src/Application/Mail/MemberLevelChangedMail.ts
47
+ import { app as app2 } from "@gravito/core";
48
+ import { Mailable as Mailable2 } from "@gravito/signal";
49
+ var MemberLevelChangedMail = class extends Mailable2 {
50
+ constructor(email, oldLevel, newLevel) {
51
+ super();
52
+ this.email = email;
53
+ this.oldLevel = oldLevel;
54
+ this.newLevel = newLevel;
55
+ }
56
+ build() {
57
+ const branding = {
58
+ name: "Gravito App",
59
+ color: "#f59e0b"
60
+ };
61
+ try {
62
+ const core = app2();
63
+ if (core) {
64
+ branding.name = core.config.get("membership.branding.name", branding.name);
65
+ branding.color = core.config.get("membership.branding.primary_color", branding.color);
66
+ }
67
+ } catch (_e) {
68
+ }
69
+ return this.to(this.email).subject(this.t("membership.emails.level_changed_subject")).view("emails/level_changed", {
70
+ oldLevel: this.oldLevel,
71
+ newLevel: this.newLevel,
72
+ branding,
73
+ currentYear: (/* @__PURE__ */ new Date()).getFullYear(),
74
+ lang: {
75
+ badge_text: this.t("membership.emails.level_changed_badge"),
76
+ title: this.t("membership.emails.level_changed_title"),
77
+ body: this.t("membership.emails.level_changed_body")
78
+ }
79
+ });
80
+ }
81
+ };
82
+
83
+ // src/Application/Mail/WelcomeMail.ts
84
+ import { app as app3 } from "@gravito/core";
85
+ import { Mailable as Mailable3 } from "@gravito/signal";
86
+ var WelcomeMail = class extends Mailable3 {
87
+ constructor(email, token) {
88
+ super();
89
+ this.email = email;
90
+ this.token = token;
91
+ }
92
+ build() {
93
+ const branding = {
94
+ name: "Gravito App",
95
+ color: "#6366f1"
96
+ };
97
+ let baseUrl = "http://localhost:3000";
98
+ try {
99
+ const core = app3();
100
+ if (core) {
101
+ branding.name = core.config.get("membership.branding.name", branding.name);
102
+ branding.color = core.config.get("membership.branding.primary_color", branding.color);
103
+ baseUrl = core.config.get("app.url", baseUrl);
104
+ }
105
+ } catch (_e) {
106
+ }
107
+ return this.to(this.email).subject(this.t("membership.emails.welcome_subject")).view("emails/welcome", {
108
+ verificationUrl: `${baseUrl}/verify?token=${this.token}`,
109
+ branding,
110
+ currentYear: (/* @__PURE__ */ new Date()).getFullYear(),
111
+ lang: {
112
+ welcome_title: this.t("membership.emails.welcome_title"),
113
+ welcome_body: this.t("membership.emails.welcome_body"),
114
+ verify_button: this.t("membership.emails.verify_button")
115
+ }
116
+ });
117
+ }
118
+ };
119
+
120
+ // src/Application/Services/PasskeysService.ts
121
+ import { AuthenticationException } from "@gravito/core";
122
+ import {
123
+ generateAuthenticationOptions,
124
+ generateRegistrationOptions,
125
+ verifyAuthenticationResponse,
126
+ verifyRegistrationResponse
127
+ } from "@simplewebauthn/server";
128
+
129
+ // src/Domain/Entities/MemberPasskey.ts
130
+ import { Entity } from "@gravito/enterprise";
131
+ var MemberPasskey = class _MemberPasskey extends Entity {
132
+ constructor(id, props) {
133
+ super(id);
134
+ this.props = props;
135
+ }
136
+ static create(params) {
137
+ const now = /* @__PURE__ */ new Date();
138
+ return new _MemberPasskey(crypto.randomUUID(), {
139
+ memberId: params.memberId,
140
+ credentialId: params.credentialId,
141
+ publicKey: params.publicKey,
142
+ counter: params.counter ?? 0,
143
+ transports: params.transports,
144
+ displayName: params.displayName,
145
+ createdAt: now,
146
+ updatedAt: now
147
+ });
148
+ }
149
+ static reconstitute(id, props) {
150
+ return new _MemberPasskey(id, props);
151
+ }
152
+ get memberId() {
153
+ return this.props.memberId;
154
+ }
155
+ get credentialId() {
156
+ return this.props.credentialId;
157
+ }
158
+ get publicKey() {
159
+ return this.props.publicKey;
160
+ }
161
+ get counter() {
162
+ return this.props.counter;
163
+ }
164
+ get transports() {
165
+ return this.props.transports;
166
+ }
167
+ get displayName() {
168
+ return this.props.displayName;
169
+ }
170
+ get createdAt() {
171
+ return this.props.createdAt;
172
+ }
173
+ get updatedAt() {
174
+ return this.props.updatedAt;
175
+ }
176
+ updateCounter(next) {
177
+ this.props.counter = next;
178
+ this.props.updatedAt = /* @__PURE__ */ new Date();
179
+ }
180
+ toRecord() {
181
+ return {
182
+ id: this.id,
183
+ member_id: this.memberId,
184
+ credential_id: this.credentialId,
185
+ public_key: this.publicKey,
186
+ counter: this.counter,
187
+ transports: this.transports ? JSON.stringify(this.transports) : null,
188
+ display_name: this.displayName,
189
+ created_at: this.createdAt,
190
+ updated_at: this.updatedAt
191
+ };
192
+ }
193
+ };
194
+
195
+ // src/Application/Services/PasskeysService.ts
196
+ var SESSION_KEYS = {
197
+ registration: "membership.passkeys.registration.challenge",
198
+ authentication: "membership.passkeys.authentication.challenge"
199
+ };
200
+ var PasskeysService = class {
201
+ constructor(passkeyRepo, config) {
202
+ this.passkeyRepo = passkeyRepo;
203
+ this.config = config;
204
+ }
205
+ async generateRegistrationOptions(member, session) {
206
+ const credentials = await this.passkeyRepo.findByMemberId(member.id);
207
+ const options = await generateRegistrationOptions({
208
+ rpName: this.config.rpName,
209
+ rpID: this.config.rpID,
210
+ userName: member.email,
211
+ userID: new TextEncoder().encode(member.id),
212
+ attestationType: this.config.attestationType ?? "none",
213
+ timeout: this.config.timeout || 6e4,
214
+ userDisplayName: member.name,
215
+ authenticatorSelection: {
216
+ userVerification: this.config.userVerification ?? "preferred"
217
+ },
218
+ excludeCredentials: credentials.map((credential) => ({
219
+ id: credential.credentialId,
220
+ type: "public-key",
221
+ transports: normalizeTransports(credential.transports)
222
+ }))
223
+ });
224
+ this.storeChallenge(session, SESSION_KEYS.registration, options.challenge);
225
+ return options;
226
+ }
227
+ async verifyRegistrationResponse(member, response, session, displayName) {
228
+ const challenge = this.requireChallenge(session, SESSION_KEYS.registration);
229
+ const verification = await verifyRegistrationResponse({
230
+ response,
231
+ expectedChallenge: challenge,
232
+ expectedOrigin: this.config.origin,
233
+ expectedRPID: this.config.rpID,
234
+ requireUserVerification: this.config.userVerification !== "discouraged"
235
+ });
236
+ this.clearChallenge(session, SESSION_KEYS.registration);
237
+ if (!verification.verified || !verification.registrationInfo) {
238
+ throw new AuthenticationException("Passkey registration could not be verified.");
239
+ }
240
+ const { credential } = verification.registrationInfo;
241
+ const passkey = MemberPasskey.create({
242
+ memberId: member.id,
243
+ credentialId: credential.id,
244
+ publicKey: Buffer.from(credential.publicKey).toString("base64"),
245
+ counter: credential.counter,
246
+ transports: credential.transports,
247
+ displayName
248
+ });
249
+ await this.passkeyRepo.save(passkey);
250
+ return passkey;
251
+ }
252
+ async generateAuthenticationOptions(member, session) {
253
+ const credentials = await this.passkeyRepo.findByMemberId(member.id);
254
+ const options = await generateAuthenticationOptions({
255
+ rpID: this.config.rpID,
256
+ timeout: this.config.timeout || 6e4,
257
+ userVerification: this.config.userVerification ?? "preferred",
258
+ allowCredentials: credentials.map((credential) => ({
259
+ id: credential.credentialId,
260
+ transports: normalizeTransports(credential.transports)
261
+ }))
262
+ });
263
+ this.storeChallenge(session, SESSION_KEYS.authentication, options.challenge);
264
+ return options;
265
+ }
266
+ async verifyAuthenticationResponse(member, response, session) {
267
+ const challenge = this.requireChallenge(session, SESSION_KEYS.authentication);
268
+ const credential = await this.passkeyRepo.findByCredentialId(response.id);
269
+ if (!credential) {
270
+ throw new AuthenticationException("Passkey not registered.");
271
+ }
272
+ if (credential.memberId !== member.id) {
273
+ throw new AuthenticationException("Passkey does not belong to the member.");
274
+ }
275
+ const verification = await verifyAuthenticationResponse({
276
+ response,
277
+ expectedChallenge: challenge,
278
+ expectedOrigin: this.config.origin,
279
+ expectedRPID: this.config.rpID,
280
+ credential: buildWebAuthnCredential(credential),
281
+ requireUserVerification: this.config.userVerification !== "discouraged"
282
+ });
283
+ this.clearChallenge(session, SESSION_KEYS.authentication);
284
+ if (!verification.verified) {
285
+ throw new AuthenticationException("Passkey authentication failed.");
286
+ }
287
+ credential.updateCounter(verification.authenticationInfo.newCounter);
288
+ await this.passkeyRepo.save(credential);
289
+ return credential;
290
+ }
291
+ storeChallenge(session, key, challenge) {
292
+ this.sessionGuard(session);
293
+ session.put(key, challenge);
294
+ }
295
+ requireChallenge(session, key) {
296
+ this.sessionGuard(session);
297
+ const challenge = session.get(key);
298
+ if (!challenge) {
299
+ throw new AuthenticationException("Passkey challenge is missing.");
300
+ }
301
+ return challenge;
302
+ }
303
+ clearChallenge(session, key) {
304
+ this.sessionGuard(session);
305
+ if (typeof session.forget === "function") {
306
+ session.forget(key);
307
+ }
308
+ }
309
+ sessionGuard(session) {
310
+ if (!session || typeof session.put !== "function" || typeof session.get !== "function") {
311
+ throw new AuthenticationException("Session store is required for Passkeys.");
312
+ }
313
+ }
314
+ };
315
+ function normalizeTransports(transports) {
316
+ return transports ? transports : void 0;
317
+ }
318
+ function buildWebAuthnCredential(passkey) {
319
+ const publicKey = Buffer.from(passkey.publicKey, "base64");
320
+ return {
321
+ id: passkey.credentialId,
322
+ publicKey,
323
+ counter: passkey.counter,
324
+ transports: normalizeTransports(passkey.transports)
325
+ };
326
+ }
327
+
328
+ // src/Application/UseCases/ForgotPassword.ts
329
+ import { UseCase } from "@gravito/enterprise";
330
+ var ForgotPassword = class extends UseCase {
331
+ constructor(repository, core) {
332
+ super();
333
+ this.repository = repository;
334
+ this.core = core;
335
+ }
336
+ async execute(input) {
337
+ const member = await this.repository.findByEmail(input.email);
338
+ if (!member) {
339
+ return;
340
+ }
341
+ member.generatePasswordResetToken();
342
+ await this.repository.save(member);
343
+ await this.core.hooks.doAction("membership:send-reset-password", {
344
+ email: member.email,
345
+ token: member.passwordResetToken
346
+ });
347
+ }
348
+ };
349
+
350
+ // src/Application/UseCases/LoginMember.ts
351
+ import { UseCase as UseCase2 } from "@gravito/enterprise";
352
+
353
+ // src/Application/DTOs/MemberDTO.ts
354
+ var MemberMapper = class {
355
+ /**
356
+ * Convert a member entity to a serializable DTO
357
+ */
358
+ static toDTO(member) {
359
+ return {
360
+ id: member.id,
361
+ name: member.name,
362
+ email: member.email,
363
+ status: member.status,
364
+ level: member.level || "standard",
365
+ roles: member.roles || [],
366
+ createdAt: member.createdAt.toISOString(),
367
+ metadata: member.metadata
368
+ };
369
+ }
370
+ };
371
+
372
+ // src/Application/UseCases/LoginMember.ts
373
+ var LoginMember = class extends UseCase2 {
374
+ constructor(repository, core) {
375
+ super();
376
+ this.repository = repository;
377
+ this.core = core;
378
+ }
379
+ async execute(input) {
380
+ const auth2 = this.core.container.make("auth");
381
+ const result = await auth2.guard("web").attempt(
382
+ {
383
+ email: input.email,
384
+ password: input.passwordPlain
385
+ },
386
+ input.remember || false
387
+ );
388
+ if (!result) {
389
+ throw new Error("Invalid credentials");
390
+ }
391
+ const user = await auth2.guard("web").user();
392
+ const singleDevice = this.core.config.get("membership.auth.single_device", false);
393
+ if (singleDevice) {
394
+ const session = this.core.container.make("session");
395
+ if (session) {
396
+ const sessionId = session.id();
397
+ const member = await this.repository.findById(user.id);
398
+ if (member) {
399
+ member.bindSession(sessionId);
400
+ await this.repository.save(member);
401
+ }
402
+ }
403
+ }
404
+ return MemberMapper.toDTO(user);
405
+ }
406
+ };
407
+
408
+ // src/Application/UseCases/RegisterMember.ts
409
+ import { UseCase as UseCase3 } from "@gravito/enterprise";
410
+
411
+ // src/Domain/Entities/Member.ts
412
+ import { Entity as Entity2 } from "@gravito/enterprise";
413
+ var Member = class _Member extends Entity2 {
414
+ constructor(id, props) {
415
+ super(id);
416
+ this.props = props;
417
+ }
418
+ static create(id, name, email, passwordHash) {
419
+ return new _Member(id, {
420
+ name,
421
+ email,
422
+ passwordHash,
423
+ status: "pending" /* PENDING */,
424
+ roles: ["member"],
425
+ verificationToken: crypto.randomUUID(),
426
+ createdAt: /* @__PURE__ */ new Date(),
427
+ updatedAt: /* @__PURE__ */ new Date()
428
+ });
429
+ }
430
+ static reconstitute(id, props) {
431
+ return new _Member(id, props);
432
+ }
433
+ // Getters
434
+ get name() {
435
+ return this.props.name;
436
+ }
437
+ get email() {
438
+ return this.props.email;
439
+ }
440
+ get status() {
441
+ return this.props.status;
442
+ }
443
+ get roles() {
444
+ return this.props.roles;
445
+ }
446
+ get passwordHash() {
447
+ return this.props.passwordHash;
448
+ }
449
+ get createdAt() {
450
+ return this.props.createdAt;
451
+ }
452
+ get emailVerifiedAt() {
453
+ return this.props.emailVerifiedAt;
454
+ }
455
+ get verificationToken() {
456
+ return this.props.verificationToken;
457
+ }
458
+ get passwordResetToken() {
459
+ return this.props.passwordResetToken;
460
+ }
461
+ get passwordResetExpiresAt() {
462
+ return this.props.passwordResetExpiresAt;
463
+ }
464
+ get currentSessionId() {
465
+ return this.props.currentSessionId;
466
+ }
467
+ get rememberToken() {
468
+ return this.props.rememberToken;
469
+ }
470
+ get metadata() {
471
+ return this.props.metadata || {};
472
+ }
473
+ // Authenticatable implementation
474
+ getAuthIdentifier() {
475
+ return this.id;
476
+ }
477
+ getAuthPassword() {
478
+ return this.props.passwordHash;
479
+ }
480
+ getRememberToken() {
481
+ return this.props.rememberToken || null;
482
+ }
483
+ setRememberToken(token) {
484
+ this.props.rememberToken = token;
485
+ this.props.updatedAt = /* @__PURE__ */ new Date();
486
+ }
487
+ /**
488
+ * Bind a session ID to this member to restrict multi-device login.
489
+ */
490
+ bindSession(sessionId) {
491
+ this.props.currentSessionId = sessionId;
492
+ this.props.updatedAt = /* @__PURE__ */ new Date();
493
+ }
494
+ /**
495
+ * Get current membership level (from metadata or default)
496
+ */
497
+ get level() {
498
+ return this.metadata.level || "standard";
499
+ }
500
+ /**
501
+ * Check if member has a specific role
502
+ */
503
+ hasRole(role) {
504
+ return this.props.roles.includes(role);
505
+ }
506
+ /**
507
+ * Assign a new role
508
+ */
509
+ addRole(role) {
510
+ if (!this.hasRole(role)) {
511
+ this.props.roles.push(role);
512
+ this.props.updatedAt = /* @__PURE__ */ new Date();
513
+ }
514
+ }
515
+ /**
516
+ * Remove a role
517
+ */
518
+ removeRole(role) {
519
+ this.props.roles = this.props.roles.filter((r) => r !== role);
520
+ this.props.updatedAt = /* @__PURE__ */ new Date();
521
+ }
522
+ /**
523
+ * Update membership level
524
+ */
525
+ changeLevel(newLevel) {
526
+ this.updateMetadata({ level: newLevel });
527
+ }
528
+ /**
529
+ * Update core profile information
530
+ */
531
+ updateProfile(name) {
532
+ this.props.name = name;
533
+ this.props.updatedAt = /* @__PURE__ */ new Date();
534
+ }
535
+ /**
536
+ * Update member password
537
+ */
538
+ changePassword(newPasswordHash) {
539
+ this.props.passwordHash = newPasswordHash;
540
+ this.props.updatedAt = /* @__PURE__ */ new Date();
541
+ }
542
+ /**
543
+ * Merges new metadata into existing metadata
544
+ */
545
+ updateMetadata(data) {
546
+ this.props.metadata = {
547
+ ...this.props.metadata || {},
548
+ ...data
549
+ };
550
+ this.props.updatedAt = /* @__PURE__ */ new Date();
551
+ }
552
+ /**
553
+ * Mark email as verified
554
+ */
555
+ verifyEmail() {
556
+ this.props.emailVerifiedAt = /* @__PURE__ */ new Date();
557
+ this.props.status = "active" /* ACTIVE */;
558
+ this.props.verificationToken = void 0;
559
+ this.props.updatedAt = /* @__PURE__ */ new Date();
560
+ }
561
+ /**
562
+ * Generate a password reset token
563
+ */
564
+ generatePasswordResetToken() {
565
+ this.props.passwordResetToken = crypto.randomUUID();
566
+ this.props.passwordResetExpiresAt = new Date(Date.now() + 36e5);
567
+ this.props.updatedAt = /* @__PURE__ */ new Date();
568
+ }
569
+ /**
570
+ * Complete password reset
571
+ */
572
+ resetPassword(newPasswordHash) {
573
+ this.props.passwordHash = newPasswordHash;
574
+ this.props.passwordResetToken = void 0;
575
+ this.props.passwordResetExpiresAt = void 0;
576
+ this.props.updatedAt = /* @__PURE__ */ new Date();
577
+ }
578
+ };
579
+
580
+ // src/Application/UseCases/RegisterMember.ts
581
+ var RegisterMember = class extends UseCase3 {
582
+ constructor(repository, core) {
583
+ super();
584
+ this.repository = repository;
585
+ this.core = core;
586
+ }
587
+ /**
588
+ * Execute the registration flow
589
+ */
590
+ async execute(input) {
591
+ const existing = await this.repository.findByEmail(input.email);
592
+ if (existing) {
593
+ const i18n = this.core.container.make("i18n");
594
+ throw new Error(i18n?.t("membership.errors.member_exists") || "Member already exists");
595
+ }
596
+ const passwordHash = await this.core.hasher.make(input.passwordPlain);
597
+ const member = Member.create(crypto.randomUUID(), input.name, input.email, passwordHash);
598
+ await this.repository.save(member);
599
+ await this.core.hooks.doAction("membership:send-verification", {
600
+ email: member.email,
601
+ token: member.verificationToken
602
+ });
603
+ await this.core.hooks.doAction("membership:registered", {
604
+ member: MemberMapper.toDTO(member)
605
+ });
606
+ return MemberMapper.toDTO(member);
607
+ }
608
+ };
609
+
610
+ // src/Application/UseCases/ResetPassword.ts
611
+ import { UseCase as UseCase4 } from "@gravito/enterprise";
612
+ var ResetPassword = class extends UseCase4 {
613
+ constructor(repository, core) {
614
+ super();
615
+ this.repository = repository;
616
+ this.core = core;
617
+ }
618
+ async execute(input) {
619
+ const member = await this.repository.findByResetToken(input.token);
620
+ if (!member || !member.passwordResetExpiresAt || member.passwordResetExpiresAt < /* @__PURE__ */ new Date()) {
621
+ throw new Error("Invalid or expired reset token");
622
+ }
623
+ const newHash = await this.core.hasher.make(input.newPasswordPlain);
624
+ member.resetPassword(newHash);
625
+ await this.repository.save(member);
626
+ }
627
+ };
628
+
629
+ // src/Application/UseCases/UpdateMemberLevel.ts
630
+ import { UseCase as UseCase5 } from "@gravito/enterprise";
631
+ var UpdateMemberLevel = class extends UseCase5 {
632
+ constructor(repository, core) {
633
+ super();
634
+ this.repository = repository;
635
+ this.core = core;
636
+ }
637
+ async execute(input) {
638
+ const member = await this.repository.findById(input.memberId);
639
+ if (!member) {
640
+ throw new Error("Member not found");
641
+ }
642
+ const oldLevel = member.level;
643
+ member.changeLevel(input.newLevel);
644
+ await this.repository.save(member);
645
+ await this.core.hooks.doAction("membership:level-changed", {
646
+ memberId: member.id,
647
+ email: member.email,
648
+ oldLevel,
649
+ newLevel: input.newLevel
650
+ });
651
+ return MemberMapper.toDTO(member);
652
+ }
653
+ };
654
+
655
+ // src/Application/UseCases/UpdateSettings.ts
656
+ import { UseCase as UseCase6 } from "@gravito/enterprise";
657
+ var UpdateSettings = class extends UseCase6 {
658
+ constructor(repository, core) {
659
+ super();
660
+ this.repository = repository;
661
+ this.core = core;
662
+ }
663
+ /**
664
+ * Execute the update flow
665
+ */
666
+ async execute(input) {
667
+ const member = await this.repository.findById(input.memberId);
668
+ if (!member) {
669
+ throw new Error("Member not found");
670
+ }
671
+ if (input.name) {
672
+ member.updateProfile(input.name);
673
+ }
674
+ if (input.newPassword) {
675
+ if (!input.currentPassword) {
676
+ throw new Error("Current password is required to set a new password");
677
+ }
678
+ const isCurrentValid = await this.core.hasher.check(
679
+ input.currentPassword,
680
+ member.passwordHash
681
+ );
682
+ if (!isCurrentValid) {
683
+ throw new Error("Invalid current password");
684
+ }
685
+ const newHash = await this.core.hasher.make(input.newPassword);
686
+ member.changePassword(newHash);
687
+ }
688
+ if (input.metadata) {
689
+ member.updateMetadata(input.metadata);
690
+ }
691
+ await this.repository.save(member);
692
+ await this.core.hooks.doAction("membership:updated", {
693
+ member: MemberMapper.toDTO(member),
694
+ updatedFields: Object.keys(input).filter((k) => k !== "memberId" && k !== "currentPassword")
695
+ });
696
+ return MemberMapper.toDTO(member);
697
+ }
698
+ };
699
+
700
+ // src/Application/UseCases/VerifyEmail.ts
701
+ import { UseCase as UseCase7 } from "@gravito/enterprise";
702
+ var VerifyEmail = class extends UseCase7 {
703
+ constructor(repository) {
704
+ super();
705
+ this.repository = repository;
706
+ }
707
+ async execute(input) {
708
+ const member = await this.repository.findByVerificationToken(input.token);
709
+ if (!member) {
710
+ throw new Error("Invalid verification token");
711
+ }
712
+ member.verifyEmail();
713
+ await this.repository.save(member);
714
+ }
715
+ };
716
+
717
+ // src/Infrastructure/Auth/SentinelMemberProvider.ts
718
+ var SentinelMemberProvider = class {
719
+ constructor(repository) {
720
+ this.repository = repository;
721
+ }
722
+ async retrieveById(id) {
723
+ const member = await this.repository.findById(id);
724
+ return member ? this.toAuthenticatable(member) : null;
725
+ }
726
+ async retrieveByToken(id, token) {
727
+ const member = await this.repository.findById(id);
728
+ if (member && member.getRememberToken() === token) {
729
+ return member;
730
+ }
731
+ return null;
732
+ }
733
+ async updateRememberToken(user, token) {
734
+ const member = await this.repository.findById(user.getAuthIdentifier());
735
+ if (member) {
736
+ member.setRememberToken(token);
737
+ await this.repository.save(member);
738
+ }
739
+ }
740
+ async retrieveByCredentials(credentials) {
741
+ if (!credentials.email) {
742
+ return null;
743
+ }
744
+ return await this.repository.findByEmail(credentials.email);
745
+ }
746
+ async validateCredentials(_user, _credentials) {
747
+ return true;
748
+ }
749
+ toAuthenticatable(member) {
750
+ return member;
751
+ }
752
+ };
753
+
754
+ // src/Infrastructure/Persistence/AtlasMemberPasskeyRepository.ts
755
+ import { DB } from "@gravito/atlas";
756
+ var AtlasMemberPasskeyRepository = class {
757
+ table = "member_passkeys";
758
+ async save(entity) {
759
+ const record = entity.toRecord();
760
+ const exists = await this.exists(entity.id);
761
+ if (exists) {
762
+ await DB.table(this.table).where("id", entity.id).update(record);
763
+ } else {
764
+ await DB.table(this.table).insert(record);
765
+ }
766
+ }
767
+ async findById(id) {
768
+ const row = await DB.table(this.table).where("id", id).first();
769
+ return row ? this.map(row) : null;
770
+ }
771
+ async findAll() {
772
+ const rows = await DB.table(this.table).get();
773
+ return rows.map((row) => this.map(row));
774
+ }
775
+ async delete(id) {
776
+ await DB.table(this.table).where("id", id).delete();
777
+ }
778
+ async exists(id) {
779
+ const count = await DB.table(this.table).where("id", id).count();
780
+ return count > 0;
781
+ }
782
+ async findByMemberId(memberId) {
783
+ const rows = await DB.table(this.table).where("member_id", memberId).get();
784
+ return rows.map((row) => this.map(row));
785
+ }
786
+ async findByCredentialId(credentialId) {
787
+ const row = await DB.table(this.table).where("credential_id", credentialId).first();
788
+ return row ? this.map(row) : null;
789
+ }
790
+ async deleteByCredentialId(credentialId) {
791
+ await DB.table(this.table).where("credential_id", credentialId).delete();
792
+ }
793
+ map(row) {
794
+ return MemberPasskey.reconstitute(row.id, {
795
+ memberId: row.member_id,
796
+ credentialId: row.credential_id,
797
+ publicKey: row.public_key,
798
+ counter: Number(row.counter ?? 0),
799
+ transports: row.transports ? JSON.parse(row.transports) : void 0,
800
+ displayName: row.display_name || void 0,
801
+ createdAt: new Date(row.created_at),
802
+ updatedAt: row.updated_at ? new Date(row.updated_at) : new Date(row.created_at)
803
+ });
804
+ }
805
+ };
806
+
807
+ // src/Infrastructure/Persistence/AtlasMemberRepository.ts
808
+ import { DB as DB2 } from "@gravito/atlas";
809
+ var AtlasMemberRepository = class {
810
+ table = "members";
811
+ async save(member) {
812
+ const data = {
813
+ id: member.id,
814
+ name: member.name,
815
+ email: member.email,
816
+ password_hash: member.passwordHash,
817
+ status: member.status,
818
+ roles: JSON.stringify(member.roles),
819
+ verification_token: member.verificationToken || null,
820
+ email_verified_at: member.emailVerifiedAt || null,
821
+ password_reset_token: member.passwordResetToken || null,
822
+ password_reset_expires_at: member.passwordResetExpiresAt || null,
823
+ current_session_id: member.currentSessionId || null,
824
+ remember_token: member.rememberToken || null,
825
+ created_at: member.createdAt,
826
+ metadata: JSON.stringify(member.metadata)
827
+ };
828
+ const exists = await this.exists(member.id);
829
+ if (exists) {
830
+ await DB2.table(this.table).where("id", member.id).update(data);
831
+ } else {
832
+ await DB2.table(this.table).insert(data);
833
+ }
834
+ }
835
+ async findByEmail(email) {
836
+ const row = await DB2.table(this.table).where("email", email).first();
837
+ return row ? this.mapToDomain(row) : null;
838
+ }
839
+ async findByVerificationToken(token) {
840
+ const row = await DB2.table(this.table).where("verification_token", token).first();
841
+ return row ? this.mapToDomain(row) : null;
842
+ }
843
+ async findByResetToken(token) {
844
+ const row = await DB2.table(this.table).where("password_reset_token", token).first();
845
+ return row ? this.mapToDomain(row) : null;
846
+ }
847
+ async findById(id) {
848
+ const row = await DB2.table(this.table).where("id", id).first();
849
+ return row ? this.mapToDomain(row) : null;
850
+ }
851
+ async findAll() {
852
+ const rows = await DB2.table(this.table).get();
853
+ return rows.map((row) => this.mapToDomain(row));
854
+ }
855
+ async delete(id) {
856
+ await DB2.table(this.table).where("id", id).delete();
857
+ }
858
+ async exists(id) {
859
+ const count = await DB2.table(this.table).where("id", id).count();
860
+ return count > 0;
861
+ }
862
+ mapToDomain(row) {
863
+ return Member.reconstitute(row.id, {
864
+ name: row.name,
865
+ email: row.email,
866
+ passwordHash: row.password_hash,
867
+ status: row.status,
868
+ roles: row.roles ? JSON.parse(row.roles) : ["member"],
869
+ verificationToken: row.verification_token,
870
+ emailVerifiedAt: row.email_verified_at ? new Date(row.email_verified_at) : void 0,
871
+ passwordResetToken: row.password_reset_token,
872
+ passwordResetExpiresAt: row.password_reset_expires_at ? new Date(row.password_reset_expires_at) : void 0,
873
+ currentSessionId: row.current_session_id,
874
+ rememberToken: row.remember_token,
875
+ createdAt: new Date(row.created_at),
876
+ updatedAt: new Date(row.updated_at || row.created_at),
877
+ metadata: row.metadata ? JSON.parse(row.metadata) : {}
878
+ });
879
+ }
880
+ };
881
+
882
+ // src/Interface/Http/Controllers/PasskeyController.ts
883
+ import { AuthenticationException as AuthenticationException2 } from "@gravito/core";
884
+ var PasskeyController = class {
885
+ constructor(passkeys, members) {
886
+ this.passkeys = passkeys;
887
+ this.members = members;
888
+ }
889
+ async registrationOptions(c) {
890
+ const auth2 = c.get("auth");
891
+ if (!auth2) {
892
+ throw new AuthenticationException2("Auth manager is unavailable.");
893
+ }
894
+ const member = await auth2.authenticate();
895
+ const session = this.resolveSession(c);
896
+ const options = await this.passkeys.generateRegistrationOptions(member, session);
897
+ return c.json(options);
898
+ }
899
+ async verifyRegistration(c) {
900
+ const auth2 = c.get("auth");
901
+ if (!auth2) {
902
+ throw new AuthenticationException2("Auth manager is unavailable.");
903
+ }
904
+ const member = await auth2.authenticate();
905
+ const session = this.resolveSession(c);
906
+ const body = await c.req.json();
907
+ await this.passkeys.verifyRegistrationResponse(
908
+ member,
909
+ body.credential,
910
+ session,
911
+ body.displayName
912
+ );
913
+ return c.json({ success: true });
914
+ }
915
+ async loginOptions(c) {
916
+ const session = this.resolveSession(c);
917
+ const body = await c.req.json();
918
+ if (!body?.email) {
919
+ return c.json({ error: "Email is required" }, 400);
920
+ }
921
+ const member = await this.members.findByEmail(body.email);
922
+ if (!member) {
923
+ return c.json({ error: "Member not found" }, 404);
924
+ }
925
+ const options = await this.passkeys.generateAuthenticationOptions(member, session);
926
+ return c.json(options);
927
+ }
928
+ async verifyAuthentication(c) {
929
+ const session = this.resolveSession(c);
930
+ const body = await c.req.json();
931
+ if (!body?.email || !body.assertion) {
932
+ return c.json({ error: "Email and assertion are required" }, 400);
933
+ }
934
+ const member = await this.members.findByEmail(body.email);
935
+ if (!member) {
936
+ return c.json({ error: "Member not found" }, 404);
937
+ }
938
+ await this.passkeys.verifyAuthenticationResponse(member, body.assertion, session);
939
+ const auth2 = c.get("auth");
940
+ if (!auth2) {
941
+ throw new AuthenticationException2("Auth manager is unavailable.");
942
+ }
943
+ await auth2.login(member);
944
+ return c.json({ success: true });
945
+ }
946
+ resolveSession(c) {
947
+ const session = c.get("session");
948
+ if (session) {
949
+ return session;
950
+ }
951
+ const fallback = c.req.session;
952
+ if (fallback) {
953
+ return fallback;
954
+ }
955
+ throw new AuthenticationException2("Session storage is required for Passkeys.");
956
+ }
957
+ };
958
+
959
+ // src/index.ts
960
+ var __dirname = fileURLToPath(new URL(".", import.meta.url));
961
+ var MembershipServiceProvider = class extends ServiceProvider {
962
+ /**
963
+ * Register bindings in the container
964
+ */
965
+ register(container) {
966
+ if (!container.has("cache")) {
967
+ const cacheFromServices = this.core?.services.get("cache");
968
+ if (cacheFromServices) {
969
+ container.instance("cache", cacheFromServices);
970
+ }
971
+ }
972
+ container.singleton("membership.repo", () => new AtlasMemberRepository());
973
+ container.singleton(
974
+ "auth.member_provider",
975
+ () => new SentinelMemberProvider(container.make("membership.repo"))
976
+ );
977
+ container.singleton("membership.register", () => {
978
+ return new RegisterMember(container.make("membership.repo"), this.core);
979
+ });
980
+ container.singleton("membership.login", () => {
981
+ return new LoginMember(container.make("membership.repo"), this.core);
982
+ });
983
+ container.singleton("membership.forgot-password", () => {
984
+ return new ForgotPassword(container.make("membership.repo"), this.core);
985
+ });
986
+ container.singleton("membership.reset-password", () => {
987
+ return new ResetPassword(container.make("membership.repo"), this.core);
988
+ });
989
+ container.singleton("membership.verify-email", () => {
990
+ return new VerifyEmail(container.make("membership.repo"));
991
+ });
992
+ container.singleton("membership.update-settings", () => {
993
+ return new UpdateSettings(container.make("membership.repo"), this.core);
994
+ });
995
+ container.singleton("membership.update-level", () => {
996
+ return new UpdateMemberLevel(container.make("membership.repo"), this.core);
997
+ });
998
+ container.singleton("membership.passkeys.repo", () => new AtlasMemberPasskeyRepository());
999
+ container.singleton("membership.passkeys.service", () => {
1000
+ return new PasskeysService(
1001
+ container.make("membership.passkeys.repo"),
1002
+ this.buildPasskeysConfig()
1003
+ );
1004
+ });
1005
+ container.singleton("membership.passkeys.controller", () => {
1006
+ return new PasskeyController(
1007
+ container.make("membership.passkeys.service"),
1008
+ container.make("membership.repo")
1009
+ );
1010
+ });
1011
+ }
1012
+ /**
1013
+ * Expose migration path using Bun-native __dirname
1014
+ */
1015
+ getMigrationsPath() {
1016
+ return `${__dirname}/Infrastructure/Persistence/Migrations`;
1017
+ }
1018
+ /**
1019
+ * Boot the satellite
1020
+ * Loads local translations into the global i18n system.
1021
+ */
1022
+ async boot() {
1023
+ const logger = this.core?.logger;
1024
+ const i18n = this.core?.container.make("i18n");
1025
+ if (i18n) {
1026
+ try {
1027
+ const en = JSON.parse(readFileSync(join(__dirname, "../locales/en.json"), "utf-8"));
1028
+ const zhTW = JSON.parse(readFileSync(join(__dirname, "../locales/zh-TW.json"), "utf-8"));
1029
+ i18n.addResource("en", "membership", en);
1030
+ i18n.addResource("zh-TW", "membership", zhTW);
1031
+ } catch (_err) {
1032
+ logger?.warn("[Membership] Failed to load localizations");
1033
+ }
1034
+ }
1035
+ if (this.core) {
1036
+ this.core.hooks.addAction(
1037
+ "membership:send-verification",
1038
+ async (data) => {
1039
+ try {
1040
+ const mail = this.core?.container.make("mail");
1041
+ if (mail) {
1042
+ await mail.queue(new WelcomeMail(data.email, data.token));
1043
+ }
1044
+ } catch (err) {
1045
+ this.core?.logger.error("[Membership] Failed to send verification email", err);
1046
+ }
1047
+ }
1048
+ );
1049
+ this.core.hooks.addAction(
1050
+ "membership:send-reset-password",
1051
+ async (data) => {
1052
+ try {
1053
+ const mail = this.core?.container.make("mail");
1054
+ if (mail) {
1055
+ await mail.queue(new ForgotPasswordMail(data.email, data.token));
1056
+ }
1057
+ } catch (err) {
1058
+ this.core?.logger.error("[Membership] Failed to send reset password email", err);
1059
+ }
1060
+ }
1061
+ );
1062
+ this.core.hooks.addAction(
1063
+ "membership:level-changed",
1064
+ async (data) => {
1065
+ try {
1066
+ const mail = this.core?.container.make("mail");
1067
+ if (mail) {
1068
+ await mail.queue(new MemberLevelChangedMail(data.email, data.oldLevel, data.newLevel));
1069
+ }
1070
+ } catch (err) {
1071
+ this.core?.logger.error("[Membership] Failed to send level change email", err);
1072
+ }
1073
+ }
1074
+ );
1075
+ }
1076
+ if (this.core) {
1077
+ const passkeyController = this.core.container.make(
1078
+ "membership.passkeys.controller"
1079
+ );
1080
+ const passkeyRoutes = this.core.router.prefix("/api/membership/passkeys");
1081
+ passkeyRoutes.post("/login/options", (c) => passkeyController.loginOptions(c));
1082
+ passkeyRoutes.post("/login/verify", (c) => passkeyController.verifyAuthentication(c));
1083
+ const protectedRoutes = passkeyRoutes.middleware(auth());
1084
+ protectedRoutes.post("/register/options", (c) => passkeyController.registrationOptions(c));
1085
+ protectedRoutes.post("/register/verify", (c) => passkeyController.verifyRegistration(c));
1086
+ }
1087
+ const startMsg = i18n?.t("membership.notifications.operational") || "\u{1F6F0}\uFE0F Satellite Membership is operational";
1088
+ logger?.info(startMsg);
1089
+ }
1090
+ buildPasskeysConfig() {
1091
+ const config = this.core?.config;
1092
+ const readString = (key) => {
1093
+ const value = config?.get(key);
1094
+ return typeof value === "string" ? value : void 0;
1095
+ };
1096
+ const origin = readString("membership.passkeys.origin") ?? readString("APP_URL") ?? process.env.APP_URL ?? "http://localhost:3000";
1097
+ const rpID = readString("membership.passkeys.rp_id") ?? (() => {
1098
+ try {
1099
+ return new URL(origin).hostname;
1100
+ } catch {
1101
+ return origin;
1102
+ }
1103
+ })();
1104
+ const rpName = readString("membership.passkeys.name") ?? "Gravito Membership";
1105
+ const timeoutValue = config?.get("membership.passkeys.timeout");
1106
+ const timeout = typeof timeoutValue === "number" ? timeoutValue : typeof timeoutValue === "string" ? Number(timeoutValue) : 6e4;
1107
+ const userVerification = readString("membership.passkeys.user_verification") ?? "preferred";
1108
+ const attestation = readString("membership.passkeys.attestation") ?? "none";
1109
+ return {
1110
+ rpName,
1111
+ rpID,
1112
+ origin,
1113
+ timeout,
1114
+ userVerification,
1115
+ attestationType: attestation
1116
+ };
1117
+ }
1118
+ };
1119
+ export {
1120
+ MembershipServiceProvider
1121
+ };