@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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQL Server verification token repository adapter.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type sql from "mssql";
|
|
6
|
+
import { internal, notFound } from "../../../core/errors/app-error.js";
|
|
7
|
+
import type { AppError } from "../../../core/errors/app-error.js";
|
|
8
|
+
import type {
|
|
9
|
+
VerificationTokenRepository,
|
|
10
|
+
VerificationTokenType,
|
|
11
|
+
} from "../../../core/ports/verification-token.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
|
+
export const createMssqlVerificationTokenRepo = (
|
|
18
|
+
pool: sql.ConnectionPool,
|
|
19
|
+
): 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 pool
|
|
38
|
+
.request()
|
|
39
|
+
.input("id", id)
|
|
40
|
+
.input("userId", userId as string)
|
|
41
|
+
.input("type", type)
|
|
42
|
+
.input("tokenHash", tokenHash)
|
|
43
|
+
.input("expiresAt", expiresAt)
|
|
44
|
+
.input("createdAt", now)
|
|
45
|
+
.query(`
|
|
46
|
+
INSERT INTO verification_tokens (id, user_id, type, token_hash, expires_at, created_at)
|
|
47
|
+
VALUES (@id, @userId, @type, @tokenHash, @expiresAt, @createdAt)
|
|
48
|
+
`);
|
|
49
|
+
|
|
50
|
+
return ok(rawToken);
|
|
51
|
+
} catch (e: unknown) {
|
|
52
|
+
return err(internal("Database error", e));
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async verify(rawToken: string, type: VerificationTokenType): Promise<Result<UserId, AppError>> {
|
|
57
|
+
try {
|
|
58
|
+
const encoder = new TextEncoder();
|
|
59
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawToken));
|
|
60
|
+
const tokenHash = [...new Uint8Array(hashBuffer)]
|
|
61
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
62
|
+
.join("");
|
|
63
|
+
|
|
64
|
+
const result = await pool
|
|
65
|
+
.request()
|
|
66
|
+
.input("tokenHash", tokenHash)
|
|
67
|
+
.input("type", type)
|
|
68
|
+
.input("now", Date.now())
|
|
69
|
+
.query(`
|
|
70
|
+
SELECT * FROM verification_tokens
|
|
71
|
+
WHERE token_hash = @tokenHash AND type = @type AND used_at IS NULL AND expires_at > @now
|
|
72
|
+
`);
|
|
73
|
+
|
|
74
|
+
if (result.recordset.length === 0) return err(notFound("Verification token"));
|
|
75
|
+
|
|
76
|
+
const row = result.recordset[0];
|
|
77
|
+
await pool
|
|
78
|
+
.request()
|
|
79
|
+
.input("usedAt", Date.now())
|
|
80
|
+
.input("id", row.id)
|
|
81
|
+
.query("UPDATE verification_tokens SET used_at = @usedAt WHERE id = @id");
|
|
82
|
+
|
|
83
|
+
return ok(brand<string, "UserId">(row.user_id as string));
|
|
84
|
+
} catch (e: unknown) {
|
|
85
|
+
return err(internal("Database error", e));
|
|
86
|
+
}
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
async invalidateAll(
|
|
90
|
+
userId: UserId,
|
|
91
|
+
type: VerificationTokenType,
|
|
92
|
+
): Promise<Result<void, AppError>> {
|
|
93
|
+
try {
|
|
94
|
+
await pool
|
|
95
|
+
.request()
|
|
96
|
+
.input("usedAt", Date.now())
|
|
97
|
+
.input("userId", userId as string)
|
|
98
|
+
.input("type", type)
|
|
99
|
+
.query(`
|
|
100
|
+
UPDATE verification_tokens SET used_at = @usedAt
|
|
101
|
+
WHERE user_id = @userId AND type = @type AND used_at IS NULL
|
|
102
|
+
`);
|
|
103
|
+
return ok(undefined);
|
|
104
|
+
} catch (e: unknown) {
|
|
105
|
+
return err(internal("Database error", e));
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
async prune(): Promise<Result<number, AppError>> {
|
|
110
|
+
try {
|
|
111
|
+
const result = await pool
|
|
112
|
+
.request()
|
|
113
|
+
.input("now", Date.now())
|
|
114
|
+
.query("DELETE FROM verification_tokens WHERE expires_at <= @now OR used_at IS NOT NULL");
|
|
115
|
+
return ok(result.rowsAffected[0] ?? 0);
|
|
116
|
+
} catch (e: unknown) {
|
|
117
|
+
return err(internal("Database error", e));
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL adapters — barrel export.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export { createPgUserRepository } from "./pg-user.repository.js";
|
|
6
|
+
export { createPgTokenBlacklist } from "./pg-token-blacklist.js";
|
|
7
|
+
export { createPgAccountLockout } from "./pg-account-lockout.js";
|
|
8
|
+
export { createPgAuditLog } from "./pg-audit-log.js";
|
|
9
|
+
export { createPgVerificationTokenRepo } from "./pg-verification-tokens.js";
|
|
10
|
+
export { createPgRefreshTokenStore } from "./pg-refresh-token-store.js";
|
|
11
|
+
export { createPgApiKeyRepository } from "./pg-api-keys.js";
|
|
12
|
+
export { createPgPasswordHistory } from "./pg-password-history.js";
|
|
13
|
+
export { createPgOAuthAccountRepo } from "./pg-oauth-accounts.js";
|
|
14
|
+
export { pgMigrateUp, pgMigrateDown } from "./migrations.js";
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Postgres migration runner — uses Bun.sql (zero external deps).
|
|
3
|
+
*
|
|
4
|
+
* Mirrors the SQLite migration runner pattern but uses PostgreSQL DDL.
|
|
5
|
+
* Migrations are idempotent and wrapped in transactions.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Logger } from "../../../core/ports/logger.js";
|
|
9
|
+
|
|
10
|
+
interface PgMigration {
|
|
11
|
+
readonly version: string;
|
|
12
|
+
readonly name: string;
|
|
13
|
+
readonly up: string; // raw SQL
|
|
14
|
+
readonly down: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* All Postgres migrations — inlined SQL strings.
|
|
19
|
+
* PostgreSQL uses BIGINT for timestamps (ms since epoch), TEXT for IDs,
|
|
20
|
+
* BOOLEAN instead of INTEGER 0/1.
|
|
21
|
+
*/
|
|
22
|
+
const migrations: readonly PgMigration[] = [
|
|
23
|
+
{
|
|
24
|
+
version: "001",
|
|
25
|
+
name: "create_users",
|
|
26
|
+
up: `
|
|
27
|
+
CREATE TABLE IF NOT EXISTS users (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
email TEXT NOT NULL UNIQUE,
|
|
30
|
+
password_hash TEXT NOT NULL,
|
|
31
|
+
role TEXT NOT NULL DEFAULT 'user',
|
|
32
|
+
email_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
|
33
|
+
mfa_secret TEXT,
|
|
34
|
+
mfa_enabled BOOLEAN NOT NULL DEFAULT FALSE,
|
|
35
|
+
password_changed_at BIGINT,
|
|
36
|
+
failed_login_attempts INTEGER NOT NULL DEFAULT 0,
|
|
37
|
+
locked_until BIGINT,
|
|
38
|
+
created_at BIGINT NOT NULL,
|
|
39
|
+
updated_at BIGINT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
|
42
|
+
`,
|
|
43
|
+
down: `
|
|
44
|
+
DROP INDEX IF EXISTS idx_users_email;
|
|
45
|
+
DROP TABLE IF EXISTS users;
|
|
46
|
+
`,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
version: "002",
|
|
50
|
+
name: "create_token_blacklist",
|
|
51
|
+
up: `
|
|
52
|
+
CREATE TABLE IF NOT EXISTS token_blacklist (
|
|
53
|
+
token_hash TEXT PRIMARY KEY,
|
|
54
|
+
expires_at BIGINT NOT NULL,
|
|
55
|
+
created_at BIGINT NOT NULL
|
|
56
|
+
);
|
|
57
|
+
CREATE INDEX IF NOT EXISTS idx_token_blacklist_expires ON token_blacklist(expires_at);
|
|
58
|
+
`,
|
|
59
|
+
down: `
|
|
60
|
+
DROP INDEX IF EXISTS idx_token_blacklist_expires;
|
|
61
|
+
DROP TABLE IF EXISTS token_blacklist;
|
|
62
|
+
`,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
version: "003",
|
|
66
|
+
name: "create_audit_log",
|
|
67
|
+
up: `
|
|
68
|
+
CREATE TABLE IF NOT EXISTS audit_log (
|
|
69
|
+
id TEXT PRIMARY KEY,
|
|
70
|
+
user_id TEXT,
|
|
71
|
+
action TEXT NOT NULL,
|
|
72
|
+
resource TEXT NOT NULL,
|
|
73
|
+
resource_id TEXT,
|
|
74
|
+
detail TEXT,
|
|
75
|
+
ip TEXT NOT NULL,
|
|
76
|
+
timestamp BIGINT NOT NULL
|
|
77
|
+
);
|
|
78
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_user ON audit_log(user_id);
|
|
79
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_action ON audit_log(action);
|
|
80
|
+
CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);
|
|
81
|
+
`,
|
|
82
|
+
down: `
|
|
83
|
+
DROP INDEX IF EXISTS idx_audit_log_timestamp;
|
|
84
|
+
DROP INDEX IF EXISTS idx_audit_log_action;
|
|
85
|
+
DROP INDEX IF EXISTS idx_audit_log_user;
|
|
86
|
+
DROP TABLE IF EXISTS audit_log;
|
|
87
|
+
`,
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
version: "004",
|
|
91
|
+
name: "auth_platform",
|
|
92
|
+
up: `
|
|
93
|
+
CREATE TABLE IF NOT EXISTS verification_tokens (
|
|
94
|
+
id TEXT PRIMARY KEY,
|
|
95
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
96
|
+
type TEXT NOT NULL CHECK (type IN ('email_verification', 'password_reset')),
|
|
97
|
+
token_hash TEXT NOT NULL UNIQUE,
|
|
98
|
+
expires_at BIGINT NOT NULL,
|
|
99
|
+
used_at BIGINT,
|
|
100
|
+
created_at BIGINT NOT NULL
|
|
101
|
+
);
|
|
102
|
+
CREATE INDEX IF NOT EXISTS idx_verification_tokens_hash ON verification_tokens(token_hash);
|
|
103
|
+
CREATE INDEX IF NOT EXISTS idx_verification_tokens_user ON verification_tokens(user_id, type);
|
|
104
|
+
|
|
105
|
+
CREATE TABLE IF NOT EXISTS refresh_token_families (
|
|
106
|
+
id TEXT PRIMARY KEY,
|
|
107
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
108
|
+
current_token_hash TEXT NOT NULL,
|
|
109
|
+
revoked BOOLEAN NOT NULL DEFAULT FALSE,
|
|
110
|
+
created_at BIGINT NOT NULL,
|
|
111
|
+
updated_at BIGINT NOT NULL
|
|
112
|
+
);
|
|
113
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_families_user ON refresh_token_families(user_id);
|
|
114
|
+
CREATE INDEX IF NOT EXISTS idx_refresh_families_token ON refresh_token_families(current_token_hash);
|
|
115
|
+
|
|
116
|
+
CREATE TABLE IF NOT EXISTS api_keys (
|
|
117
|
+
id TEXT PRIMARY KEY,
|
|
118
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
119
|
+
name TEXT NOT NULL,
|
|
120
|
+
key_hash TEXT NOT NULL UNIQUE,
|
|
121
|
+
key_prefix TEXT NOT NULL,
|
|
122
|
+
scopes TEXT NOT NULL DEFAULT '[]',
|
|
123
|
+
expires_at BIGINT,
|
|
124
|
+
last_used_at BIGINT,
|
|
125
|
+
created_at BIGINT NOT NULL
|
|
126
|
+
);
|
|
127
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS idx_api_keys_user ON api_keys(user_id);
|
|
129
|
+
|
|
130
|
+
CREATE TABLE IF NOT EXISTS password_history (
|
|
131
|
+
id TEXT PRIMARY KEY,
|
|
132
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
133
|
+
password_hash TEXT NOT NULL,
|
|
134
|
+
created_at BIGINT NOT NULL
|
|
135
|
+
);
|
|
136
|
+
CREATE INDEX IF NOT EXISTS idx_password_history_user ON password_history(user_id);
|
|
137
|
+
|
|
138
|
+
CREATE TABLE IF NOT EXISTS oauth_accounts (
|
|
139
|
+
id TEXT PRIMARY KEY,
|
|
140
|
+
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
141
|
+
provider TEXT NOT NULL,
|
|
142
|
+
provider_user_id TEXT NOT NULL,
|
|
143
|
+
email TEXT,
|
|
144
|
+
created_at BIGINT NOT NULL,
|
|
145
|
+
UNIQUE(provider, provider_user_id)
|
|
146
|
+
);
|
|
147
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_user ON oauth_accounts(user_id);
|
|
148
|
+
CREATE INDEX IF NOT EXISTS idx_oauth_accounts_provider ON oauth_accounts(provider, provider_user_id);
|
|
149
|
+
`,
|
|
150
|
+
down: `
|
|
151
|
+
DROP TABLE IF EXISTS oauth_accounts;
|
|
152
|
+
DROP TABLE IF EXISTS password_history;
|
|
153
|
+
DROP TABLE IF EXISTS api_keys;
|
|
154
|
+
DROP TABLE IF EXISTS refresh_token_families;
|
|
155
|
+
DROP TABLE IF EXISTS verification_tokens;
|
|
156
|
+
`,
|
|
157
|
+
},
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun.sql returns generic tagged template type
|
|
161
|
+
type PgClient = any;
|
|
162
|
+
|
|
163
|
+
const ensureMigrationsTable = async (sql: PgClient): Promise<void> => {
|
|
164
|
+
await sql`
|
|
165
|
+
CREATE TABLE IF NOT EXISTS _migrations (
|
|
166
|
+
version TEXT PRIMARY KEY,
|
|
167
|
+
name TEXT NOT NULL,
|
|
168
|
+
applied_at BIGINT NOT NULL
|
|
169
|
+
)
|
|
170
|
+
`;
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const getAppliedVersions = async (sql: PgClient): Promise<Set<string>> => {
|
|
174
|
+
const rows = await sql`SELECT version FROM _migrations ORDER BY version`;
|
|
175
|
+
return new Set(rows.map((r: { version: string }) => r.version));
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
export const pgMigrateUp = async (sql: PgClient, logger: Logger): Promise<number> => {
|
|
179
|
+
await ensureMigrationsTable(sql);
|
|
180
|
+
const applied = await getAppliedVersions(sql);
|
|
181
|
+
let count = 0;
|
|
182
|
+
|
|
183
|
+
for (const migration of migrations) {
|
|
184
|
+
if (applied.has(migration.version)) continue;
|
|
185
|
+
|
|
186
|
+
await sql.begin(async (tx: PgClient) => {
|
|
187
|
+
// Execute raw SQL statements
|
|
188
|
+
for (const stmt of migration.up.split(";").filter((s: string) => s.trim())) {
|
|
189
|
+
await tx.unsafe(stmt.trim());
|
|
190
|
+
}
|
|
191
|
+
await tx`
|
|
192
|
+
INSERT INTO _migrations (version, name, applied_at)
|
|
193
|
+
VALUES (${migration.version}, ${migration.name}, ${Date.now()})
|
|
194
|
+
`;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
logger.info("Migration applied", {
|
|
198
|
+
version: migration.version,
|
|
199
|
+
name: migration.name,
|
|
200
|
+
});
|
|
201
|
+
count++;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (count > 0) {
|
|
205
|
+
logger.info("Postgres migrations complete", { applied: count });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return count;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
export const pgMigrateDown = async (sql: PgClient, logger: Logger): Promise<string | null> => {
|
|
212
|
+
await ensureMigrationsTable(sql);
|
|
213
|
+
const applied = await getAppliedVersions(sql);
|
|
214
|
+
|
|
215
|
+
// Find the highest applied migration
|
|
216
|
+
const reversed = [...migrations].reverse();
|
|
217
|
+
for (const migration of reversed) {
|
|
218
|
+
if (!applied.has(migration.version)) continue;
|
|
219
|
+
|
|
220
|
+
await sql.begin(async (tx: PgClient) => {
|
|
221
|
+
for (const stmt of migration.down.split(";").filter((s: string) => s.trim())) {
|
|
222
|
+
await tx.unsafe(stmt.trim());
|
|
223
|
+
}
|
|
224
|
+
await tx`DELETE FROM _migrations WHERE version = ${migration.version}`;
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
logger.info("Migration rolled back", {
|
|
228
|
+
version: migration.version,
|
|
229
|
+
name: migration.name,
|
|
230
|
+
});
|
|
231
|
+
return migration.version;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return null;
|
|
235
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL account lockout 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 { AccountLockout } from "../../../core/ports/account-lockout.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
|
+
interface LockoutOptions {
|
|
14
|
+
readonly maxAttempts: number;
|
|
15
|
+
readonly lockoutDurationMs: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export const createPgAccountLockout = (sql: PgClient, options: LockoutOptions): AccountLockout => ({
|
|
19
|
+
async recordFailedAttempt(email: string): Promise<Result<boolean, AppError>> {
|
|
20
|
+
try {
|
|
21
|
+
await sql`
|
|
22
|
+
UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE email = ${email}
|
|
23
|
+
`;
|
|
24
|
+
const rows = await sql`
|
|
25
|
+
SELECT failed_login_attempts FROM users WHERE email = ${email}
|
|
26
|
+
`;
|
|
27
|
+
if (rows.length === 0) return ok(false);
|
|
28
|
+
|
|
29
|
+
const attempts = Number(rows[0].failed_login_attempts);
|
|
30
|
+
if (attempts >= options.maxAttempts) {
|
|
31
|
+
const lockUntil = Date.now() + options.lockoutDurationMs;
|
|
32
|
+
await sql`
|
|
33
|
+
UPDATE users SET locked_until = ${lockUntil}, failed_login_attempts = ${attempts} WHERE email = ${email}
|
|
34
|
+
`;
|
|
35
|
+
return ok(true);
|
|
36
|
+
}
|
|
37
|
+
return ok(false);
|
|
38
|
+
} catch (e: unknown) {
|
|
39
|
+
return err(internal("Database error", e));
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
|
|
43
|
+
async resetAttempts(email: string): Promise<Result<void, AppError>> {
|
|
44
|
+
try {
|
|
45
|
+
await sql`
|
|
46
|
+
UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE email = ${email}
|
|
47
|
+
`;
|
|
48
|
+
return ok(undefined);
|
|
49
|
+
} catch (e: unknown) {
|
|
50
|
+
return err(internal("Database error", e));
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
async isLocked(email: string): Promise<Result<number | null, AppError>> {
|
|
55
|
+
try {
|
|
56
|
+
const rows = await sql`
|
|
57
|
+
SELECT locked_until FROM users WHERE email = ${email}
|
|
58
|
+
`;
|
|
59
|
+
if (rows.length === 0) return ok(null);
|
|
60
|
+
|
|
61
|
+
const lockedUntil = rows[0].locked_until;
|
|
62
|
+
if (lockedUntil === null || lockedUntil === undefined) return ok(null);
|
|
63
|
+
if (Number(lockedUntil) <= Date.now()) {
|
|
64
|
+
// Lock expired — clear it
|
|
65
|
+
await sql`
|
|
66
|
+
UPDATE users SET locked_until = NULL, failed_login_attempts = 0 WHERE email = ${email}
|
|
67
|
+
`;
|
|
68
|
+
return ok(null);
|
|
69
|
+
}
|
|
70
|
+
return ok(Number(lockedUntil));
|
|
71
|
+
} catch (e: unknown) {
|
|
72
|
+
return err(internal("Database error", e));
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL API key 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 { ApiKey, ApiKeyRepository } from "../../../core/ports/api-key.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 KEY_PREFIX = "oapi_";
|
|
17
|
+
|
|
18
|
+
const rowToApiKey = (row: Record<string, unknown>): ApiKey => ({
|
|
19
|
+
id: row["id"] as string,
|
|
20
|
+
userId: brand<string, "UserId">(row["user_id"] as string),
|
|
21
|
+
name: row["name"] as string,
|
|
22
|
+
keyPrefix: row["key_prefix"] as string,
|
|
23
|
+
scopes: JSON.parse((row["scopes"] as string) || "[]") as readonly string[],
|
|
24
|
+
expiresAt: row["expires_at"] !== null ? Number(row["expires_at"]) : null,
|
|
25
|
+
lastUsedAt: row["last_used_at"] !== null ? Number(row["last_used_at"]) : null,
|
|
26
|
+
createdAt: Number(row["created_at"]),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const createPgApiKeyRepository = (sql: PgClient): ApiKeyRepository => ({
|
|
30
|
+
async create(
|
|
31
|
+
userId: UserId,
|
|
32
|
+
name: string,
|
|
33
|
+
scopes: readonly string[],
|
|
34
|
+
expiresAt?: number,
|
|
35
|
+
): Promise<Result<{ key: ApiKey; rawKey: string }, AppError>> {
|
|
36
|
+
try {
|
|
37
|
+
const id = generateId();
|
|
38
|
+
const rawKey = `${KEY_PREFIX}${generateId()}${generateId()}`;
|
|
39
|
+
const prefix = rawKey.substring(0, KEY_PREFIX.length + 8);
|
|
40
|
+
|
|
41
|
+
const encoder = new TextEncoder();
|
|
42
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawKey));
|
|
43
|
+
const keyHash = [...new Uint8Array(hashBuffer)]
|
|
44
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
45
|
+
.join("");
|
|
46
|
+
|
|
47
|
+
const now = Date.now();
|
|
48
|
+
|
|
49
|
+
await sql`
|
|
50
|
+
INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, scopes, expires_at, created_at)
|
|
51
|
+
VALUES (${id}, ${userId as string}, ${name}, ${keyHash}, ${prefix}, ${JSON.stringify(scopes)}, ${expiresAt ?? null}, ${now})
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
const key: ApiKey = {
|
|
55
|
+
id,
|
|
56
|
+
userId,
|
|
57
|
+
name,
|
|
58
|
+
keyPrefix: prefix,
|
|
59
|
+
scopes,
|
|
60
|
+
expiresAt: expiresAt ?? null,
|
|
61
|
+
lastUsedAt: null,
|
|
62
|
+
createdAt: now,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return ok({ key, rawKey });
|
|
66
|
+
} catch (e: unknown) {
|
|
67
|
+
return err(internal("Database error", e));
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async verify(rawKey: string): Promise<Result<ApiKey, AppError>> {
|
|
72
|
+
try {
|
|
73
|
+
const encoder = new TextEncoder();
|
|
74
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(rawKey));
|
|
75
|
+
const keyHash = [...new Uint8Array(hashBuffer)]
|
|
76
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
77
|
+
.join("");
|
|
78
|
+
|
|
79
|
+
const rows = await sql`SELECT * FROM api_keys WHERE key_hash = ${keyHash}`;
|
|
80
|
+
if (rows.length === 0) return err(notFound("API key"));
|
|
81
|
+
|
|
82
|
+
const key = rowToApiKey(rows[0] as Record<string, unknown>);
|
|
83
|
+
if (key.expiresAt !== null && key.expiresAt < Date.now()) {
|
|
84
|
+
return err(notFound("API key"));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return ok(key);
|
|
88
|
+
} catch (e: unknown) {
|
|
89
|
+
return err(internal("Database error", e));
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
async listByUser(userId: UserId): Promise<Result<readonly ApiKey[], AppError>> {
|
|
94
|
+
try {
|
|
95
|
+
const rows = await sql`
|
|
96
|
+
SELECT * FROM api_keys WHERE user_id = ${userId as string} ORDER BY created_at DESC
|
|
97
|
+
`;
|
|
98
|
+
return ok(rows.map((r: Record<string, unknown>) => rowToApiKey(r)));
|
|
99
|
+
} catch (e: unknown) {
|
|
100
|
+
return err(internal("Database error", e));
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async revoke(id: string, userId: UserId): Promise<Result<void, AppError>> {
|
|
105
|
+
try {
|
|
106
|
+
const rows = await sql`
|
|
107
|
+
SELECT id FROM api_keys WHERE id = ${id} AND user_id = ${userId as string}
|
|
108
|
+
`;
|
|
109
|
+
if (rows.length === 0) return err(notFound("API key"));
|
|
110
|
+
|
|
111
|
+
await sql`DELETE FROM api_keys WHERE id = ${id}`;
|
|
112
|
+
return ok(undefined);
|
|
113
|
+
} catch (e: unknown) {
|
|
114
|
+
return err(internal("Database error", e));
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
async touch(id: string): Promise<Result<void, AppError>> {
|
|
119
|
+
try {
|
|
120
|
+
await sql`UPDATE api_keys SET last_used_at = ${Date.now()} WHERE id = ${id}`;
|
|
121
|
+
return ok(undefined);
|
|
122
|
+
} catch (e: unknown) {
|
|
123
|
+
return err(internal("Database error", e));
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostgreSQL audit log 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 { AuditEntry, AuditLog, AuditQueryOptions } from "../../../core/ports/audit-log.js";
|
|
8
|
+
import { type Result, err, ok } from "../../../core/types/result.js";
|
|
9
|
+
import { generateId } from "../../../shared/utils/id.js";
|
|
10
|
+
|
|
11
|
+
// biome-ignore lint/suspicious/noExplicitAny: Bun.sql tagged template type
|
|
12
|
+
type PgClient = any;
|
|
13
|
+
|
|
14
|
+
export const createPgAuditLog = (sql: PgClient): AuditLog => ({
|
|
15
|
+
async append(entry: Omit<AuditEntry, "id" | "timestamp">): Promise<Result<void, AppError>> {
|
|
16
|
+
try {
|
|
17
|
+
const id = generateId();
|
|
18
|
+
const timestamp = Date.now();
|
|
19
|
+
await sql`
|
|
20
|
+
INSERT INTO audit_log (id, user_id, action, resource, resource_id, detail, ip, timestamp)
|
|
21
|
+
VALUES (${id}, ${entry.userId}, ${entry.action}, ${entry.resource}, ${entry.resourceId}, ${entry.detail}, ${entry.ip}, ${timestamp})
|
|
22
|
+
`;
|
|
23
|
+
return ok(undefined);
|
|
24
|
+
} catch (e: unknown) {
|
|
25
|
+
return err(internal("Database error", e));
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async query(options: AuditQueryOptions): Promise<Result<readonly AuditEntry[], AppError>> {
|
|
30
|
+
try {
|
|
31
|
+
const conditions: string[] = [];
|
|
32
|
+
const params: unknown[] = [];
|
|
33
|
+
let idx = 1;
|
|
34
|
+
|
|
35
|
+
if (options.userId !== undefined) {
|
|
36
|
+
conditions.push(`user_id = $${idx++}`);
|
|
37
|
+
params.push(options.userId);
|
|
38
|
+
}
|
|
39
|
+
if (options.action !== undefined) {
|
|
40
|
+
conditions.push(`action = $${idx++}`);
|
|
41
|
+
params.push(options.action);
|
|
42
|
+
}
|
|
43
|
+
if (options.since !== undefined) {
|
|
44
|
+
conditions.push(`timestamp >= $${idx++}`);
|
|
45
|
+
params.push(options.since);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
49
|
+
const limit = options.limit ?? 50;
|
|
50
|
+
params.push(limit);
|
|
51
|
+
|
|
52
|
+
const query = `SELECT * FROM audit_log ${where} ORDER BY timestamp DESC LIMIT $${idx}`;
|
|
53
|
+
const rows = await sql.unsafe(query, params);
|
|
54
|
+
|
|
55
|
+
return ok(
|
|
56
|
+
rows.map(
|
|
57
|
+
(r: Record<string, unknown>) =>
|
|
58
|
+
({
|
|
59
|
+
id: r["id"] as string,
|
|
60
|
+
userId: (r["user_id"] as string) ?? null,
|
|
61
|
+
action: r["action"],
|
|
62
|
+
resource: r["resource"] as string,
|
|
63
|
+
resourceId: (r["resource_id"] as string) ?? null,
|
|
64
|
+
detail: (r["detail"] as string) ?? null,
|
|
65
|
+
ip: r["ip"] as string,
|
|
66
|
+
timestamp: Number(r["timestamp"]),
|
|
67
|
+
}) as AuditEntry,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
} catch (e: unknown) {
|
|
71
|
+
return err(internal("Database error", e));
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
});
|