@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,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
|
+
});
|