@enderworld/onlyapi 1.5.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 (160) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/LICENSE +21 -0
  3. package/README.md +338 -0
  4. package/dist/cli.js +14 -0
  5. package/package.json +69 -0
  6. package/src/application/dtos/admin.dto.ts +25 -0
  7. package/src/application/dtos/auth.dto.ts +97 -0
  8. package/src/application/dtos/index.ts +40 -0
  9. package/src/application/index.ts +2 -0
  10. package/src/application/services/admin.service.ts +150 -0
  11. package/src/application/services/api-key.service.ts +65 -0
  12. package/src/application/services/auth.service.ts +606 -0
  13. package/src/application/services/health.service.ts +97 -0
  14. package/src/application/services/index.ts +10 -0
  15. package/src/application/services/user.service.ts +95 -0
  16. package/src/cli/commands/help.ts +86 -0
  17. package/src/cli/commands/init.ts +301 -0
  18. package/src/cli/commands/upgrade.ts +471 -0
  19. package/src/cli/index.ts +76 -0
  20. package/src/cli/ui.ts +189 -0
  21. package/src/cluster.ts +62 -0
  22. package/src/core/entities/index.ts +1 -0
  23. package/src/core/entities/user.entity.ts +24 -0
  24. package/src/core/errors/app-error.ts +81 -0
  25. package/src/core/errors/index.ts +15 -0
  26. package/src/core/index.ts +7 -0
  27. package/src/core/ports/account-lockout.ts +15 -0
  28. package/src/core/ports/alert-sink.ts +27 -0
  29. package/src/core/ports/api-key.ts +37 -0
  30. package/src/core/ports/audit-log.ts +46 -0
  31. package/src/core/ports/cache.ts +24 -0
  32. package/src/core/ports/circuit-breaker.ts +42 -0
  33. package/src/core/ports/event-bus.ts +78 -0
  34. package/src/core/ports/index.ts +62 -0
  35. package/src/core/ports/job-queue.ts +73 -0
  36. package/src/core/ports/logger.ts +21 -0
  37. package/src/core/ports/metrics.ts +49 -0
  38. package/src/core/ports/oauth.ts +55 -0
  39. package/src/core/ports/password-hasher.ts +10 -0
  40. package/src/core/ports/password-history.ts +23 -0
  41. package/src/core/ports/password-policy.ts +43 -0
  42. package/src/core/ports/refresh-token-store.ts +37 -0
  43. package/src/core/ports/retry.ts +23 -0
  44. package/src/core/ports/token-blacklist.ts +16 -0
  45. package/src/core/ports/token-service.ts +23 -0
  46. package/src/core/ports/totp-service.ts +16 -0
  47. package/src/core/ports/user.repository.ts +40 -0
  48. package/src/core/ports/verification-token.ts +41 -0
  49. package/src/core/ports/webhook.ts +58 -0
  50. package/src/core/types/brand.ts +19 -0
  51. package/src/core/types/index.ts +19 -0
  52. package/src/core/types/pagination.ts +28 -0
  53. package/src/core/types/result.ts +52 -0
  54. package/src/infrastructure/alerting/index.ts +1 -0
  55. package/src/infrastructure/alerting/webhook.ts +100 -0
  56. package/src/infrastructure/cache/in-memory-cache.ts +111 -0
  57. package/src/infrastructure/cache/index.ts +6 -0
  58. package/src/infrastructure/cache/redis-cache.ts +204 -0
  59. package/src/infrastructure/config/config.ts +185 -0
  60. package/src/infrastructure/config/index.ts +1 -0
  61. package/src/infrastructure/database/in-memory-user.repository.ts +134 -0
  62. package/src/infrastructure/database/index.ts +37 -0
  63. package/src/infrastructure/database/migrations/001_create_users.ts +26 -0
  64. package/src/infrastructure/database/migrations/002_create_token_blacklist.ts +21 -0
  65. package/src/infrastructure/database/migrations/003_create_audit_log.ts +31 -0
  66. package/src/infrastructure/database/migrations/004_auth_platform.ts +112 -0
  67. package/src/infrastructure/database/migrations/runner.ts +120 -0
  68. package/src/infrastructure/database/mssql/index.ts +14 -0
  69. package/src/infrastructure/database/mssql/migrations.ts +299 -0
  70. package/src/infrastructure/database/mssql/mssql-account-lockout.ts +95 -0
  71. package/src/infrastructure/database/mssql/mssql-api-keys.ts +146 -0
  72. package/src/infrastructure/database/mssql/mssql-audit-log.ts +86 -0
  73. package/src/infrastructure/database/mssql/mssql-oauth-accounts.ts +118 -0
  74. package/src/infrastructure/database/mssql/mssql-password-history.ts +71 -0
  75. package/src/infrastructure/database/mssql/mssql-refresh-token-store.ts +144 -0
  76. package/src/infrastructure/database/mssql/mssql-token-blacklist.ts +54 -0
  77. package/src/infrastructure/database/mssql/mssql-user.repository.ts +263 -0
  78. package/src/infrastructure/database/mssql/mssql-verification-tokens.ts +120 -0
  79. package/src/infrastructure/database/postgres/index.ts +14 -0
  80. package/src/infrastructure/database/postgres/migrations.ts +235 -0
  81. package/src/infrastructure/database/postgres/pg-account-lockout.ts +75 -0
  82. package/src/infrastructure/database/postgres/pg-api-keys.ts +126 -0
  83. package/src/infrastructure/database/postgres/pg-audit-log.ts +74 -0
  84. package/src/infrastructure/database/postgres/pg-oauth-accounts.ts +101 -0
  85. package/src/infrastructure/database/postgres/pg-password-history.ts +61 -0
  86. package/src/infrastructure/database/postgres/pg-refresh-token-store.ts +117 -0
  87. package/src/infrastructure/database/postgres/pg-token-blacklist.ts +48 -0
  88. package/src/infrastructure/database/postgres/pg-user.repository.ts +237 -0
  89. package/src/infrastructure/database/postgres/pg-verification-tokens.ts +97 -0
  90. package/src/infrastructure/database/sqlite-account-lockout.ts +97 -0
  91. package/src/infrastructure/database/sqlite-api-keys.ts +155 -0
  92. package/src/infrastructure/database/sqlite-audit-log.ts +90 -0
  93. package/src/infrastructure/database/sqlite-oauth-accounts.ts +105 -0
  94. package/src/infrastructure/database/sqlite-password-history.ts +54 -0
  95. package/src/infrastructure/database/sqlite-refresh-token-store.ts +122 -0
  96. package/src/infrastructure/database/sqlite-token-blacklist.ts +47 -0
  97. package/src/infrastructure/database/sqlite-user.repository.ts +260 -0
  98. package/src/infrastructure/database/sqlite-verification-tokens.ts +112 -0
  99. package/src/infrastructure/events/event-bus.ts +105 -0
  100. package/src/infrastructure/events/event-factory.ts +31 -0
  101. package/src/infrastructure/events/in-memory-webhook-registry.ts +67 -0
  102. package/src/infrastructure/events/index.ts +4 -0
  103. package/src/infrastructure/events/webhook-dispatcher.ts +114 -0
  104. package/src/infrastructure/index.ts +58 -0
  105. package/src/infrastructure/jobs/index.ts +1 -0
  106. package/src/infrastructure/jobs/job-queue.ts +185 -0
  107. package/src/infrastructure/logging/index.ts +1 -0
  108. package/src/infrastructure/logging/logger.ts +63 -0
  109. package/src/infrastructure/metrics/index.ts +1 -0
  110. package/src/infrastructure/metrics/prometheus.ts +231 -0
  111. package/src/infrastructure/oauth/github.ts +116 -0
  112. package/src/infrastructure/oauth/google.ts +83 -0
  113. package/src/infrastructure/oauth/index.ts +2 -0
  114. package/src/infrastructure/resilience/circuit-breaker.ts +133 -0
  115. package/src/infrastructure/resilience/index.ts +2 -0
  116. package/src/infrastructure/resilience/retry.ts +50 -0
  117. package/src/infrastructure/security/account-lockout.ts +73 -0
  118. package/src/infrastructure/security/index.ts +6 -0
  119. package/src/infrastructure/security/password-hasher.ts +31 -0
  120. package/src/infrastructure/security/password-policy.ts +77 -0
  121. package/src/infrastructure/security/token-blacklist.ts +45 -0
  122. package/src/infrastructure/security/token-service.ts +144 -0
  123. package/src/infrastructure/security/totp-service.ts +142 -0
  124. package/src/infrastructure/tracing/index.ts +7 -0
  125. package/src/infrastructure/tracing/trace-context.ts +93 -0
  126. package/src/main.ts +479 -0
  127. package/src/presentation/context.ts +26 -0
  128. package/src/presentation/handlers/admin.handler.ts +114 -0
  129. package/src/presentation/handlers/api-key.handler.ts +68 -0
  130. package/src/presentation/handlers/auth.handler.ts +218 -0
  131. package/src/presentation/handlers/health.handler.ts +27 -0
  132. package/src/presentation/handlers/index.ts +15 -0
  133. package/src/presentation/handlers/metrics.handler.ts +21 -0
  134. package/src/presentation/handlers/oauth.handler.ts +61 -0
  135. package/src/presentation/handlers/openapi.handler.ts +543 -0
  136. package/src/presentation/handlers/response.ts +29 -0
  137. package/src/presentation/handlers/sse.handler.ts +165 -0
  138. package/src/presentation/handlers/user.handler.ts +81 -0
  139. package/src/presentation/handlers/webhook.handler.ts +92 -0
  140. package/src/presentation/handlers/websocket.handler.ts +226 -0
  141. package/src/presentation/i18n/index.ts +254 -0
  142. package/src/presentation/index.ts +5 -0
  143. package/src/presentation/middleware/api-key.ts +18 -0
  144. package/src/presentation/middleware/auth.ts +39 -0
  145. package/src/presentation/middleware/cors.ts +41 -0
  146. package/src/presentation/middleware/index.ts +12 -0
  147. package/src/presentation/middleware/rate-limit.ts +65 -0
  148. package/src/presentation/middleware/security-headers.ts +18 -0
  149. package/src/presentation/middleware/validate.ts +16 -0
  150. package/src/presentation/middleware/versioning.ts +69 -0
  151. package/src/presentation/routes/index.ts +1 -0
  152. package/src/presentation/routes/router.ts +272 -0
  153. package/src/presentation/server.ts +381 -0
  154. package/src/shared/cli.ts +294 -0
  155. package/src/shared/container.ts +65 -0
  156. package/src/shared/index.ts +2 -0
  157. package/src/shared/log-format.ts +148 -0
  158. package/src/shared/utils/id.ts +5 -0
  159. package/src/shared/utils/index.ts +2 -0
  160. package/src/shared/utils/timing-safe.ts +20 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * PostgreSQL OAuth account repository adapter.
3
+ */
4
+
5
+ import { conflict, internal, notFound } from "../../../core/errors/app-error.js";
6
+ import type { AppError } from "../../../core/errors/app-error.js";
7
+ import type { OAuthAccount, OAuthAccountRepository } from "../../../core/ports/oauth.js";
8
+ import type { UserId } from "../../../core/types/brand.js";
9
+ import { brand } from "../../../core/types/brand.js";
10
+ import { type Result, err, ok } from "../../../core/types/result.js";
11
+ import { generateId } from "../../../shared/utils/id.js";
12
+
13
+ // biome-ignore lint/suspicious/noExplicitAny: Bun.sql tagged template type
14
+ type PgClient = any;
15
+
16
+ const toOAuthAccount = (row: Record<string, unknown>): OAuthAccount => ({
17
+ id: row["id"] as string,
18
+ userId: brand<string, "UserId">(row["user_id"] as string),
19
+ provider: row["provider"] as string,
20
+ providerUserId: row["provider_user_id"] as string,
21
+ email: (row["email"] as string) ?? null,
22
+ createdAt: Number(row["created_at"]),
23
+ });
24
+
25
+ export const createPgOAuthAccountRepo = (sql: PgClient): OAuthAccountRepository => ({
26
+ async link(
27
+ userId: UserId,
28
+ provider: string,
29
+ providerUserId: string,
30
+ email: string | null,
31
+ ): Promise<Result<OAuthAccount, AppError>> {
32
+ try {
33
+ const id = generateId();
34
+ const now = Date.now();
35
+
36
+ try {
37
+ await sql`
38
+ INSERT INTO oauth_accounts (id, user_id, provider, provider_user_id, email, created_at)
39
+ VALUES (${id}, ${userId as string}, ${provider}, ${providerUserId}, ${email}, ${now})
40
+ `;
41
+ } catch (e: unknown) {
42
+ if (e instanceof Error && e.message.includes("duplicate key")) {
43
+ return err(conflict("OAuth account already linked"));
44
+ }
45
+ throw e;
46
+ }
47
+
48
+ return ok({
49
+ id,
50
+ userId,
51
+ provider,
52
+ providerUserId,
53
+ email,
54
+ createdAt: now,
55
+ });
56
+ } catch (e: unknown) {
57
+ return err(internal("Database error", e));
58
+ }
59
+ },
60
+
61
+ async findByProvider(
62
+ provider: string,
63
+ providerUserId: string,
64
+ ): Promise<Result<OAuthAccount | null, AppError>> {
65
+ try {
66
+ const rows = await sql`
67
+ SELECT * FROM oauth_accounts
68
+ WHERE provider = ${provider} AND provider_user_id = ${providerUserId}
69
+ `;
70
+ if (rows.length === 0) return ok(null);
71
+ return ok(toOAuthAccount(rows[0] as Record<string, unknown>));
72
+ } catch (e: unknown) {
73
+ return err(internal("Database error", e));
74
+ }
75
+ },
76
+
77
+ async listByUser(userId: UserId): Promise<Result<readonly OAuthAccount[], AppError>> {
78
+ try {
79
+ const rows = await sql`
80
+ SELECT * FROM oauth_accounts WHERE user_id = ${userId as string} ORDER BY created_at DESC
81
+ `;
82
+ return ok(rows.map((r: Record<string, unknown>) => toOAuthAccount(r)));
83
+ } catch (e: unknown) {
84
+ return err(internal("Database error", e));
85
+ }
86
+ },
87
+
88
+ async unlink(id: string, userId: UserId): Promise<Result<void, AppError>> {
89
+ try {
90
+ const rows = await sql`
91
+ SELECT id FROM oauth_accounts WHERE id = ${id} AND user_id = ${userId as string}
92
+ `;
93
+ if (rows.length === 0) return err(notFound("OAuth account"));
94
+
95
+ await sql`DELETE FROM oauth_accounts WHERE id = ${id}`;
96
+ return ok(undefined);
97
+ } catch (e: unknown) {
98
+ return err(internal("Database error", e));
99
+ }
100
+ },
101
+ });
@@ -0,0 +1,61 @@
1
+ /**
2
+ * PostgreSQL password history adapter.
3
+ */
4
+
5
+ import { internal } from "../../../core/errors/app-error.js";
6
+ import type { AppError } from "../../../core/errors/app-error.js";
7
+ import type { PasswordHistory } from "../../../core/ports/password-history.js";
8
+ import type { UserId } from "../../../core/types/brand.js";
9
+ import { type Result, err, ok } from "../../../core/types/result.js";
10
+ import { generateId } from "../../../shared/utils/id.js";
11
+
12
+ // biome-ignore lint/suspicious/noExplicitAny: Bun.sql tagged template type
13
+ type PgClient = any;
14
+
15
+ export const createPgPasswordHistory = (sql: PgClient): PasswordHistory => ({
16
+ async add(userId: UserId, passwordHash: string): Promise<Result<void, AppError>> {
17
+ try {
18
+ const id = generateId();
19
+ await sql`
20
+ INSERT INTO password_history (id, user_id, password_hash, created_at)
21
+ VALUES (${id}, ${userId as string}, ${passwordHash}, ${Date.now()})
22
+ `;
23
+ return ok(undefined);
24
+ } catch (e: unknown) {
25
+ return err(internal("Database error", e));
26
+ }
27
+ },
28
+
29
+ async getRecent(userId: UserId, count: number): Promise<Result<readonly string[], AppError>> {
30
+ try {
31
+ const rows = await sql`
32
+ SELECT password_hash FROM password_history
33
+ WHERE user_id = ${userId as string}
34
+ ORDER BY created_at DESC
35
+ LIMIT ${count}
36
+ `;
37
+ return ok(rows.map((r: { password_hash: string }) => r.password_hash));
38
+ } catch (e: unknown) {
39
+ return err(internal("Database error", e));
40
+ }
41
+ },
42
+
43
+ async prune(userId: UserId, keepCount: number): Promise<Result<void, AppError>> {
44
+ try {
45
+ // Delete all but the most recent `keepCount` entries
46
+ await sql`
47
+ DELETE FROM password_history
48
+ WHERE user_id = ${userId as string}
49
+ AND id NOT IN (
50
+ SELECT id FROM password_history
51
+ WHERE user_id = ${userId as string}
52
+ ORDER BY created_at DESC
53
+ LIMIT ${keepCount}
54
+ )
55
+ `;
56
+ return ok(undefined);
57
+ } catch (e: unknown) {
58
+ return err(internal("Database error", e));
59
+ }
60
+ },
61
+ });
@@ -0,0 +1,117 @@
1
+ /**
2
+ * PostgreSQL refresh token store adapter.
3
+ */
4
+
5
+ import { conflict, internal, notFound } from "../../../core/errors/app-error.js";
6
+ import type { AppError } from "../../../core/errors/app-error.js";
7
+ import type {
8
+ RefreshTokenFamily,
9
+ RefreshTokenStore,
10
+ } from "../../../core/ports/refresh-token-store.js";
11
+ import type { UserId } from "../../../core/types/brand.js";
12
+ import { brand } from "../../../core/types/brand.js";
13
+ import { type Result, err, ok } from "../../../core/types/result.js";
14
+ import { generateId } from "../../../shared/utils/id.js";
15
+
16
+ // biome-ignore lint/suspicious/noExplicitAny: Bun.sql tagged template type
17
+ type PgClient = any;
18
+
19
+ const toFamily = (row: Record<string, unknown>): RefreshTokenFamily => ({
20
+ id: row["id"] as string,
21
+ userId: brand<string, "UserId">(row["user_id"] as string),
22
+ currentTokenHash: row["current_token_hash"] as string,
23
+ revoked: row["revoked"] as boolean,
24
+ createdAt: Number(row["created_at"]),
25
+ updatedAt: Number(row["updated_at"]),
26
+ });
27
+
28
+ export const createPgRefreshTokenStore = (sql: PgClient): RefreshTokenStore => ({
29
+ async createFamily(userId: UserId, tokenHash: string): Promise<Result<string, AppError>> {
30
+ try {
31
+ const id = generateId();
32
+ const now = Date.now();
33
+ await sql`
34
+ INSERT INTO refresh_token_families (id, user_id, current_token_hash, revoked, created_at, updated_at)
35
+ VALUES (${id}, ${userId as string}, ${tokenHash}, FALSE, ${now}, ${now})
36
+ `;
37
+ return ok(id);
38
+ } catch (e: unknown) {
39
+ return err(internal("Database error", e));
40
+ }
41
+ },
42
+
43
+ async rotate(
44
+ familyId: string,
45
+ oldTokenHash: string,
46
+ newTokenHash: string,
47
+ ): Promise<Result<void, AppError>> {
48
+ try {
49
+ const rows = await sql`
50
+ SELECT * FROM refresh_token_families WHERE id = ${familyId} AND revoked = FALSE
51
+ `;
52
+ if (rows.length === 0) return err(notFound("Refresh token family"));
53
+
54
+ const family = rows[0];
55
+ if (family.current_token_hash !== oldTokenHash) {
56
+ // Reuse detected — revoke entire family
57
+ await sql`UPDATE refresh_token_families SET revoked = TRUE, updated_at = ${Date.now()} WHERE id = ${familyId}`;
58
+ return err(conflict("Token reuse detected"));
59
+ }
60
+
61
+ await sql`
62
+ UPDATE refresh_token_families
63
+ SET current_token_hash = ${newTokenHash}, updated_at = ${Date.now()}
64
+ WHERE id = ${familyId}
65
+ `;
66
+ return ok(undefined);
67
+ } catch (e: unknown) {
68
+ return err(internal("Database error", e));
69
+ }
70
+ },
71
+
72
+ async findByTokenHash(tokenHash: string): Promise<Result<RefreshTokenFamily | null, AppError>> {
73
+ try {
74
+ const rows = await sql`
75
+ SELECT * FROM refresh_token_families WHERE current_token_hash = ${tokenHash}
76
+ `;
77
+ if (rows.length === 0) return ok(null);
78
+ return ok(toFamily(rows[0] as Record<string, unknown>));
79
+ } catch (e: unknown) {
80
+ return err(internal("Database error", e));
81
+ }
82
+ },
83
+
84
+ async revokeFamily(familyId: string): Promise<Result<void, AppError>> {
85
+ try {
86
+ await sql`
87
+ UPDATE refresh_token_families SET revoked = TRUE, updated_at = ${Date.now()} WHERE id = ${familyId}
88
+ `;
89
+ return ok(undefined);
90
+ } catch (e: unknown) {
91
+ return err(internal("Database error", e));
92
+ }
93
+ },
94
+
95
+ async revokeAllForUser(userId: UserId): Promise<Result<void, AppError>> {
96
+ try {
97
+ await sql`
98
+ UPDATE refresh_token_families SET revoked = TRUE, updated_at = ${Date.now()} WHERE user_id = ${userId as string}
99
+ `;
100
+ return ok(undefined);
101
+ } catch (e: unknown) {
102
+ return err(internal("Database error", e));
103
+ }
104
+ },
105
+
106
+ async prune(maxAgeMs: number): Promise<Result<number, AppError>> {
107
+ try {
108
+ const cutoff = Date.now() - maxAgeMs;
109
+ const result = await sql`
110
+ DELETE FROM refresh_token_families WHERE revoked = TRUE AND updated_at < ${cutoff}
111
+ `;
112
+ return ok(result.count ?? 0);
113
+ } catch (e: unknown) {
114
+ return err(internal("Database error", e));
115
+ }
116
+ },
117
+ });
@@ -0,0 +1,48 @@
1
+ /**
2
+ * PostgreSQL token blacklist adapter.
3
+ */
4
+
5
+ import { internal } from "../../../core/errors/app-error.js";
6
+ import type { AppError } from "../../../core/errors/app-error.js";
7
+ import type { TokenBlacklist } from "../../../core/ports/token-blacklist.js";
8
+ import { type Result, err, ok } from "../../../core/types/result.js";
9
+
10
+ // biome-ignore lint/suspicious/noExplicitAny: Bun.sql tagged template type
11
+ type PgClient = any;
12
+
13
+ export const createPgTokenBlacklist = (sql: PgClient): TokenBlacklist => ({
14
+ async add(tokenHash: string, expiresAt: number): Promise<Result<void, AppError>> {
15
+ try {
16
+ await sql`
17
+ INSERT INTO token_blacklist (token_hash, expires_at, created_at)
18
+ VALUES (${tokenHash}, ${expiresAt}, ${Date.now()})
19
+ ON CONFLICT (token_hash) DO NOTHING
20
+ `;
21
+ return ok(undefined);
22
+ } catch (e: unknown) {
23
+ return err(internal("Database error", e));
24
+ }
25
+ },
26
+
27
+ async isBlacklisted(tokenHash: string): Promise<Result<boolean, AppError>> {
28
+ try {
29
+ const rows = await sql`
30
+ SELECT 1 FROM token_blacklist WHERE token_hash = ${tokenHash} AND expires_at > ${Date.now()}
31
+ `;
32
+ return ok(rows.length > 0);
33
+ } catch (e: unknown) {
34
+ return err(internal("Database error", e));
35
+ }
36
+ },
37
+
38
+ async prune(): Promise<Result<number, AppError>> {
39
+ try {
40
+ const result = await sql`
41
+ DELETE FROM token_blacklist WHERE expires_at <= ${Date.now()}
42
+ `;
43
+ return ok(result.count ?? 0);
44
+ } catch (e: unknown) {
45
+ return err(internal("Database error", e));
46
+ }
47
+ },
48
+ });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * PostgreSQL user repository — uses Bun.sql (zero external deps).
3
+ *
4
+ * Drop-in replacement for SQLite adapter, implements the same UserRepository port.
5
+ * Uses parameterized queries to prevent SQL injection.
6
+ */
7
+
8
+ import type { User, UserRole } from "../../../core/entities/user.entity.js";
9
+ import { type AppError, conflict, internal, notFound } from "../../../core/errors/app-error.js";
10
+ import type {
11
+ CreateUserData,
12
+ UpdateUserData,
13
+ UserListOptions,
14
+ UserRepository,
15
+ } from "../../../core/ports/user.repository.js";
16
+ import type { UserId } from "../../../core/types/brand.js";
17
+ import { brand } from "../../../core/types/brand.js";
18
+ import { decodeCursor, encodeCursor } from "../../../core/types/pagination.js";
19
+ import { type Result, err, ok } from "../../../core/types/result.js";
20
+ import { generateId } from "../../../shared/utils/id.js";
21
+
22
+ interface UserRow {
23
+ id: string;
24
+ email: string;
25
+ password_hash: string;
26
+ role: string;
27
+ email_verified: boolean;
28
+ mfa_secret: string | null;
29
+ mfa_enabled: boolean;
30
+ password_changed_at: number | null;
31
+ failed_login_attempts: number;
32
+ locked_until: number | null;
33
+ created_at: number;
34
+ updated_at: number;
35
+ }
36
+
37
+ const rowToUser = (row: UserRow): User => ({
38
+ id: brand<string, "UserId">(row.id),
39
+ email: row.email,
40
+ passwordHash: row.password_hash,
41
+ role: row.role as UserRole,
42
+ emailVerified: row.email_verified,
43
+ mfaEnabled: row.mfa_enabled,
44
+ mfaSecret: row.mfa_secret,
45
+ passwordChangedAt:
46
+ row.password_changed_at !== null ? brand<number, "Timestamp">(row.password_changed_at) : null,
47
+ createdAt: brand<number, "Timestamp">(row.created_at),
48
+ updatedAt: brand<number, "Timestamp">(row.updated_at),
49
+ });
50
+
51
+ const isUniqueViolation = (e: unknown): boolean =>
52
+ e instanceof Error && (e.message.includes("duplicate key") || e.message.includes("unique"));
53
+
54
+ /** Map UpdateUserData fields to their SQL column names */
55
+ const UPDATE_FIELD_MAP: ReadonlyArray<[keyof UpdateUserData, string]> = [
56
+ ["email", "email"],
57
+ ["passwordHash", "password_hash"],
58
+ ["role", "role"],
59
+ ["emailVerified", "email_verified"],
60
+ ["mfaEnabled", "mfa_enabled"],
61
+ ["mfaSecret", "mfa_secret"],
62
+ ["passwordChangedAt", "password_changed_at"],
63
+ ];
64
+
65
+ /** Build SET clause and parameter array from update data */
66
+ const buildUpdateSets = (data: UpdateUserData): { sets: string[]; vals: unknown[] } => {
67
+ const sets: string[] = [];
68
+ const vals: unknown[] = [];
69
+ let idx = 1;
70
+
71
+ for (const [field, column] of UPDATE_FIELD_MAP) {
72
+ if (data[field] !== undefined) {
73
+ sets.push(`${column} = $${idx++}`);
74
+ vals.push(data[field]);
75
+ }
76
+ }
77
+
78
+ sets.push(`updated_at = $${idx}`);
79
+ vals.push(Date.now());
80
+
81
+ return { sets, vals };
82
+ };
83
+
84
+ // biome-ignore lint/suspicious/noExplicitAny: Bun.sql tagged template type
85
+ type PgClient = any;
86
+
87
+ export const createPgUserRepository = (sql: PgClient): UserRepository => {
88
+ return {
89
+ async findById(id: UserId): Promise<Result<User, AppError>> {
90
+ try {
91
+ const rows = await sql`SELECT * FROM users WHERE id = ${id}`;
92
+ if (rows.length === 0) return err(notFound("User"));
93
+ return ok(rowToUser(rows[0] as UserRow));
94
+ } catch (e: unknown) {
95
+ return err(internal("Database error", e));
96
+ }
97
+ },
98
+
99
+ async findByEmail(email: string): Promise<Result<User, AppError>> {
100
+ try {
101
+ const rows = await sql`SELECT * FROM users WHERE email = ${email}`;
102
+ if (rows.length === 0) return err(notFound("User"));
103
+ return ok(rowToUser(rows[0] as UserRow));
104
+ } catch (e: unknown) {
105
+ return err(internal("Database error", e));
106
+ }
107
+ },
108
+
109
+ async create(data: CreateUserData): Promise<Result<User, AppError>> {
110
+ try {
111
+ const id = generateId();
112
+ const now = Date.now();
113
+
114
+ try {
115
+ await sql`
116
+ INSERT INTO users (id, email, password_hash, role, email_verified, mfa_enabled, failed_login_attempts, created_at, updated_at)
117
+ VALUES (${id}, ${data.email}, ${data.passwordHash}, ${data.role}, FALSE, FALSE, 0, ${now}, ${now})
118
+ `;
119
+ } catch (e: unknown) {
120
+ if (isUniqueViolation(e)) return err(conflict("Email already exists"));
121
+ throw e;
122
+ }
123
+
124
+ const user: User = {
125
+ id: brand<string, "UserId">(id),
126
+ email: data.email,
127
+ passwordHash: data.passwordHash,
128
+ role: data.role,
129
+ emailVerified: false,
130
+ mfaEnabled: false,
131
+ mfaSecret: null,
132
+ passwordChangedAt: null,
133
+ createdAt: brand<number, "Timestamp">(now),
134
+ updatedAt: brand<number, "Timestamp">(now),
135
+ };
136
+
137
+ return ok(user);
138
+ } catch (e: unknown) {
139
+ return err(internal("Database error", e));
140
+ }
141
+ },
142
+
143
+ async update(id: UserId, data: UpdateUserData): Promise<Result<User, AppError>> {
144
+ try {
145
+ // Check existence first
146
+ const existing = await sql`SELECT id FROM users WHERE id = ${id}`;
147
+ if (existing.length === 0) return err(notFound("User"));
148
+
149
+ const { sets, vals } = buildUpdateSets(data);
150
+
151
+ vals.push(id); // WHERE clause param
152
+ const whereIdx = vals.length;
153
+ const query = `UPDATE users SET ${sets.join(", ")} WHERE id = $${whereIdx}`;
154
+
155
+ try {
156
+ await sql.unsafe(query, vals);
157
+ } catch (e: unknown) {
158
+ if (isUniqueViolation(e)) return err(conflict("Email already exists"));
159
+ throw e;
160
+ }
161
+
162
+ const updated = await sql`SELECT * FROM users WHERE id = ${id}`;
163
+ if (updated.length === 0) return err(internal("User disappeared after update"));
164
+ return ok(rowToUser(updated[0] as UserRow));
165
+ } catch (e: unknown) {
166
+ if (isUniqueViolation(e)) return err(conflict("Email already exists"));
167
+ return err(internal("Database error", e));
168
+ }
169
+ },
170
+
171
+ async delete(id: UserId): Promise<Result<void, AppError>> {
172
+ try {
173
+ const existing = await sql`SELECT id FROM users WHERE id = ${id}`;
174
+ if (existing.length === 0) return err(notFound("User"));
175
+
176
+ await sql`DELETE FROM users WHERE id = ${id}`;
177
+ return ok(undefined);
178
+ } catch (e: unknown) {
179
+ return err(internal("Database error", e));
180
+ }
181
+ },
182
+
183
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SQL builder with cursor/filter/search needs branching
184
+ async list(options: UserListOptions) {
185
+ try {
186
+ const conditions: string[] = [];
187
+ const params: unknown[] = [];
188
+ let idx = 1;
189
+
190
+ if (options.cursor !== undefined) {
191
+ const decoded = decodeCursor(options.cursor);
192
+ if (decoded !== null) {
193
+ conditions.push(`created_at < $${idx++}`);
194
+ params.push(Number(decoded));
195
+ }
196
+ }
197
+
198
+ if (options.role !== undefined) {
199
+ conditions.push(`role = $${idx++}`);
200
+ params.push(options.role);
201
+ }
202
+
203
+ if (options.search !== undefined) {
204
+ conditions.push(`email ILIKE $${idx++}`);
205
+ params.push(`%${options.search}%`);
206
+ }
207
+
208
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
209
+ const limit = Math.min(options.limit, 100);
210
+ params.push(limit + 1);
211
+
212
+ const query = `SELECT * FROM users ${where} ORDER BY created_at DESC LIMIT $${idx}`;
213
+ const rows = (await sql.unsafe(query, params)) as UserRow[];
214
+
215
+ const hasMore = rows.length > limit;
216
+ const items = (hasMore ? rows.slice(0, limit) : rows).map(rowToUser);
217
+
218
+ const lastItem = items[items.length - 1];
219
+ const nextCursor =
220
+ hasMore && lastItem !== undefined ? encodeCursor(String(lastItem.createdAt)) : null;
221
+
222
+ return ok({ items, nextCursor, hasMore });
223
+ } catch (e: unknown) {
224
+ return err(internal("Database error", e));
225
+ }
226
+ },
227
+
228
+ async count() {
229
+ try {
230
+ const rows = await sql`SELECT COUNT(*) as cnt FROM users`;
231
+ return ok(Number(rows[0].cnt));
232
+ } catch (e: unknown) {
233
+ return err(internal("Database error", e));
234
+ }
235
+ },
236
+ };
237
+ };
@@ -0,0 +1,97 @@
1
+ /**
2
+ * PostgreSQL verification token repository adapter.
3
+ */
4
+
5
+ import { internal, notFound } from "../../../core/errors/app-error.js";
6
+ import type { AppError } from "../../../core/errors/app-error.js";
7
+ import type {
8
+ VerificationTokenRepository,
9
+ VerificationTokenType,
10
+ } from "../../../core/ports/verification-token.js";
11
+ import type { UserId } from "../../../core/types/brand.js";
12
+ import { brand } from "../../../core/types/brand.js";
13
+ import { type Result, err, ok } from "../../../core/types/result.js";
14
+ import { generateId } from "../../../shared/utils/id.js";
15
+
16
+ // biome-ignore lint/suspicious/noExplicitAny: Bun.sql tagged template type
17
+ type PgClient = any;
18
+
19
+ export const createPgVerificationTokenRepo = (sql: PgClient): VerificationTokenRepository => ({
20
+ async create(
21
+ userId: UserId,
22
+ type: VerificationTokenType,
23
+ ttlMs: number,
24
+ ): Promise<Result<string, AppError>> {
25
+ try {
26
+ const id = generateId();
27
+ const rawToken = generateId();
28
+ const encoder = new TextEncoder();
29
+ const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawToken));
30
+ const tokenHash = [...new Uint8Array(hashBuffer)]
31
+ .map((b) => b.toString(16).padStart(2, "0"))
32
+ .join("");
33
+
34
+ const now = Date.now();
35
+ const expiresAt = now + ttlMs;
36
+
37
+ await sql`
38
+ INSERT INTO verification_tokens (id, user_id, type, token_hash, expires_at, created_at)
39
+ VALUES (${id}, ${userId as string}, ${type}, ${tokenHash}, ${expiresAt}, ${now})
40
+ `;
41
+
42
+ return ok(rawToken);
43
+ } catch (e: unknown) {
44
+ return err(internal("Database error", e));
45
+ }
46
+ },
47
+
48
+ async verify(rawToken: string, type: VerificationTokenType): Promise<Result<UserId, AppError>> {
49
+ try {
50
+ const encoder = new TextEncoder();
51
+ const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawToken));
52
+ const tokenHash = [...new Uint8Array(hashBuffer)]
53
+ .map((b) => b.toString(16).padStart(2, "0"))
54
+ .join("");
55
+
56
+ const rows = await sql`
57
+ SELECT * FROM verification_tokens
58
+ WHERE token_hash = ${tokenHash} AND type = ${type} AND used_at IS NULL AND expires_at > ${Date.now()}
59
+ `;
60
+
61
+ if (rows.length === 0) return err(notFound("Verification token"));
62
+
63
+ const row = rows[0];
64
+ await sql`UPDATE verification_tokens SET used_at = ${Date.now()} WHERE id = ${row.id}`;
65
+
66
+ return ok(brand<string, "UserId">(row.user_id as string));
67
+ } catch (e: unknown) {
68
+ return err(internal("Database error", e));
69
+ }
70
+ },
71
+
72
+ async invalidateAll(
73
+ userId: UserId,
74
+ type: VerificationTokenType,
75
+ ): Promise<Result<void, AppError>> {
76
+ try {
77
+ await sql`
78
+ UPDATE verification_tokens SET used_at = ${Date.now()}
79
+ WHERE user_id = ${userId as string} AND type = ${type} AND used_at IS NULL
80
+ `;
81
+ return ok(undefined);
82
+ } catch (e: unknown) {
83
+ return err(internal("Database error", e));
84
+ }
85
+ },
86
+
87
+ async prune(): Promise<Result<number, AppError>> {
88
+ try {
89
+ const result = await sql`
90
+ DELETE FROM verification_tokens WHERE expires_at <= ${Date.now()} OR used_at IS NOT NULL
91
+ `;
92
+ return ok(result.count ?? 0);
93
+ } catch (e: unknown) {
94
+ return err(internal("Database error", e));
95
+ }
96
+ },
97
+ });