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