@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,97 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type AppError, internal } from "../../core/errors/app-error.js";
|
|
3
|
+
import type { AccountLockout } from "../../core/ports/account-lockout.js";
|
|
4
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SQLite-backed account lockout — persists across restarts.
|
|
8
|
+
* Uses the failed_login_attempts and locked_until columns on the users table.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
interface LockoutConfig {
|
|
12
|
+
readonly maxAttempts: number;
|
|
13
|
+
readonly lockoutDurationMs: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const createSqliteAccountLockout = (
|
|
17
|
+
db: Database,
|
|
18
|
+
config: LockoutConfig = { maxAttempts: 5, lockoutDurationMs: 15 * 60 * 1000 },
|
|
19
|
+
): AccountLockout => {
|
|
20
|
+
const getStmt = db.prepare<
|
|
21
|
+
{ failed_login_attempts: number; locked_until: number | null },
|
|
22
|
+
[string]
|
|
23
|
+
>("SELECT failed_login_attempts, locked_until FROM users WHERE email = ?");
|
|
24
|
+
|
|
25
|
+
const incrementStmt = db.prepare(
|
|
26
|
+
"UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE email = ?",
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
const lockStmt = db.prepare(
|
|
30
|
+
"UPDATE users SET failed_login_attempts = ?, locked_until = ? WHERE email = ?",
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
const resetStmt = db.prepare(
|
|
34
|
+
"UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE email = ?",
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
async recordFailedAttempt(email: string): Promise<Result<boolean, AppError>> {
|
|
39
|
+
try {
|
|
40
|
+
const row = getStmt.get(email);
|
|
41
|
+
if (!row) return ok(false); // User doesn't exist — don't reveal that
|
|
42
|
+
|
|
43
|
+
const now = Date.now();
|
|
44
|
+
|
|
45
|
+
// If currently locked and not expired
|
|
46
|
+
if (row.locked_until !== null && row.locked_until > now) {
|
|
47
|
+
return ok(true);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// If lock expired, reset first
|
|
51
|
+
if (row.locked_until !== null && row.locked_until <= now) {
|
|
52
|
+
resetStmt.run(email);
|
|
53
|
+
incrementStmt.run(email);
|
|
54
|
+
return ok(false);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
incrementStmt.run(email);
|
|
58
|
+
const newCount = row.failed_login_attempts + 1;
|
|
59
|
+
|
|
60
|
+
if (newCount >= config.maxAttempts) {
|
|
61
|
+
lockStmt.run(newCount, now + config.lockoutDurationMs, email);
|
|
62
|
+
return ok(true);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return ok(false);
|
|
66
|
+
} catch (e: unknown) {
|
|
67
|
+
return err(internal("Failed to record login attempt", e));
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
|
|
71
|
+
async resetAttempts(email: string): Promise<Result<void, AppError>> {
|
|
72
|
+
try {
|
|
73
|
+
resetStmt.run(email);
|
|
74
|
+
return ok(undefined);
|
|
75
|
+
} catch (e: unknown) {
|
|
76
|
+
return err(internal("Failed to reset login attempts", e));
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async isLocked(email: string): Promise<Result<number | null, AppError>> {
|
|
81
|
+
try {
|
|
82
|
+
const row = getStmt.get(email);
|
|
83
|
+
if (!row || row.locked_until === null) return ok(null);
|
|
84
|
+
|
|
85
|
+
if (row.locked_until <= Date.now()) {
|
|
86
|
+
// Lock expired — reset
|
|
87
|
+
resetStmt.run(email);
|
|
88
|
+
return ok(null);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return ok(row.locked_until);
|
|
92
|
+
} catch (e: unknown) {
|
|
93
|
+
return err(internal("Failed to check lockout status", e));
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type AppError, internal, notFound, unauthorized } from "../../core/errors/app-error.js";
|
|
3
|
+
import type { ApiKey, ApiKeyRepository } from "../../core/ports/api-key.js";
|
|
4
|
+
import type { UserId } from "../../core/types/brand.js";
|
|
5
|
+
import { brand } from "../../core/types/brand.js";
|
|
6
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
7
|
+
import { generateId } from "../../shared/utils/id.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SQLite-backed API key repository.
|
|
11
|
+
* Keys are SHA-256 hashed before storage. Only the prefix is shown after creation.
|
|
12
|
+
* Raw keys are returned exactly once on creation.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const hashKey = async (raw: string): Promise<string> => {
|
|
16
|
+
const data = new TextEncoder().encode(raw);
|
|
17
|
+
const buf = await crypto.subtle.digest("SHA-256", data);
|
|
18
|
+
return Array.from(new Uint8Array(buf))
|
|
19
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
20
|
+
.join("");
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const generateRawKey = (): string => {
|
|
24
|
+
const bytes = new Uint8Array(32);
|
|
25
|
+
crypto.getRandomValues(bytes);
|
|
26
|
+
const hex = Array.from(bytes)
|
|
27
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
28
|
+
.join("");
|
|
29
|
+
return `oapi_${hex}`;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
interface ApiKeyRow {
|
|
33
|
+
id: string;
|
|
34
|
+
user_id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
key_hash: string;
|
|
37
|
+
key_prefix: string;
|
|
38
|
+
scopes: string;
|
|
39
|
+
expires_at: number | null;
|
|
40
|
+
last_used_at: number | null;
|
|
41
|
+
created_at: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const rowToApiKey = (row: ApiKeyRow): ApiKey => ({
|
|
45
|
+
id: row.id,
|
|
46
|
+
userId: brand<string, "UserId">(row.user_id),
|
|
47
|
+
name: row.name,
|
|
48
|
+
keyPrefix: row.key_prefix,
|
|
49
|
+
scopes: JSON.parse(row.scopes) as string[],
|
|
50
|
+
expiresAt: row.expires_at,
|
|
51
|
+
lastUsedAt: row.last_used_at,
|
|
52
|
+
createdAt: row.created_at,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export const createSqliteApiKeyRepository = (db: Database): ApiKeyRepository => {
|
|
56
|
+
const insertStmt = db.prepare(
|
|
57
|
+
"INSERT INTO api_keys (id, user_id, name, key_hash, key_prefix, scopes, expires_at, last_used_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?)",
|
|
58
|
+
);
|
|
59
|
+
const findByHashStmt = db.prepare<ApiKeyRow, [string]>(
|
|
60
|
+
"SELECT * FROM api_keys WHERE key_hash = ?",
|
|
61
|
+
);
|
|
62
|
+
const listByUserStmt = db.prepare<ApiKeyRow, [string]>(
|
|
63
|
+
"SELECT * FROM api_keys WHERE user_id = ? ORDER BY created_at DESC",
|
|
64
|
+
);
|
|
65
|
+
const deleteStmt = db.prepare("DELETE FROM api_keys WHERE id = ? AND user_id = ?");
|
|
66
|
+
const touchStmt = db.prepare("UPDATE api_keys SET last_used_at = ? WHERE id = ?");
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
async create(
|
|
70
|
+
userId: UserId,
|
|
71
|
+
name: string,
|
|
72
|
+
scopes: readonly string[],
|
|
73
|
+
expiresAt?: number,
|
|
74
|
+
): Promise<Result<{ key: ApiKey; rawKey: string }, AppError>> {
|
|
75
|
+
try {
|
|
76
|
+
const rawKey = generateRawKey();
|
|
77
|
+
const keyHash = await hashKey(rawKey);
|
|
78
|
+
const id = generateId();
|
|
79
|
+
const now = Date.now();
|
|
80
|
+
const keyPrefix = `${rawKey.substring(0, 12)}...`;
|
|
81
|
+
|
|
82
|
+
insertStmt.run(
|
|
83
|
+
id,
|
|
84
|
+
userId,
|
|
85
|
+
name,
|
|
86
|
+
keyHash,
|
|
87
|
+
keyPrefix,
|
|
88
|
+
JSON.stringify(scopes),
|
|
89
|
+
expiresAt ?? null,
|
|
90
|
+
now,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const key: ApiKey = {
|
|
94
|
+
id,
|
|
95
|
+
userId,
|
|
96
|
+
name,
|
|
97
|
+
keyPrefix,
|
|
98
|
+
scopes,
|
|
99
|
+
expiresAt: expiresAt ?? null,
|
|
100
|
+
lastUsedAt: null,
|
|
101
|
+
createdAt: now,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
return ok({ key, rawKey });
|
|
105
|
+
} catch (e: unknown) {
|
|
106
|
+
return err(internal("Failed to create API key", e));
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
|
|
110
|
+
async verify(rawKey: string): Promise<Result<ApiKey, AppError>> {
|
|
111
|
+
try {
|
|
112
|
+
const keyHash = await hashKey(rawKey);
|
|
113
|
+
const row = findByHashStmt.get(keyHash);
|
|
114
|
+
if (!row) return err(unauthorized("Invalid API key"));
|
|
115
|
+
|
|
116
|
+
// Check expiry
|
|
117
|
+
if (row.expires_at !== null && row.expires_at <= Date.now()) {
|
|
118
|
+
return err(unauthorized("API key has expired"));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return ok(rowToApiKey(row));
|
|
122
|
+
} catch (e: unknown) {
|
|
123
|
+
return err(internal("Failed to verify API key", e));
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
async listByUser(userId: UserId): Promise<Result<readonly ApiKey[], AppError>> {
|
|
128
|
+
try {
|
|
129
|
+
const rows = listByUserStmt.all(userId);
|
|
130
|
+
return ok(rows.map(rowToApiKey));
|
|
131
|
+
} catch (e: unknown) {
|
|
132
|
+
return err(internal("Failed to list API keys", e));
|
|
133
|
+
}
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async revoke(id: string, userId: UserId): Promise<Result<void, AppError>> {
|
|
137
|
+
try {
|
|
138
|
+
const result = deleteStmt.run(id, userId);
|
|
139
|
+
if (result.changes === 0) return err(notFound("API key"));
|
|
140
|
+
return ok(undefined);
|
|
141
|
+
} catch (e: unknown) {
|
|
142
|
+
return err(internal("Failed to revoke API key", e));
|
|
143
|
+
}
|
|
144
|
+
},
|
|
145
|
+
|
|
146
|
+
async touch(id: string): Promise<Result<void, AppError>> {
|
|
147
|
+
try {
|
|
148
|
+
touchStmt.run(Date.now(), id);
|
|
149
|
+
return ok(undefined);
|
|
150
|
+
} catch (e: unknown) {
|
|
151
|
+
return err(internal("Failed to update API key usage", e));
|
|
152
|
+
}
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type AppError, internal } from "../../core/errors/app-error.js";
|
|
3
|
+
import type { AuditEntry, AuditLog, AuditQueryOptions } from "../../core/ports/audit-log.js";
|
|
4
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
5
|
+
import { generateId } from "../../shared/utils/id.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* SQLite audit log — append-only ledger backed by bun:sqlite.
|
|
9
|
+
* Supports querying by userId, action, and time range.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
interface AuditRow {
|
|
13
|
+
id: string;
|
|
14
|
+
user_id: string | null;
|
|
15
|
+
action: string;
|
|
16
|
+
resource: string;
|
|
17
|
+
resource_id: string | null;
|
|
18
|
+
detail: string | null;
|
|
19
|
+
ip: string;
|
|
20
|
+
timestamp: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rowToEntry = (row: AuditRow): AuditEntry => ({
|
|
24
|
+
id: row.id,
|
|
25
|
+
userId: row.user_id,
|
|
26
|
+
action: row.action as AuditEntry["action"],
|
|
27
|
+
resource: row.resource,
|
|
28
|
+
resourceId: row.resource_id,
|
|
29
|
+
detail: row.detail,
|
|
30
|
+
ip: row.ip,
|
|
31
|
+
timestamp: row.timestamp,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
export const createSqliteAuditLog = (db: Database): AuditLog => {
|
|
35
|
+
const insertStmt = db.prepare(
|
|
36
|
+
"INSERT INTO audit_log (id, user_id, action, resource, resource_id, detail, ip, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
async append(entry: Omit<AuditEntry, "id" | "timestamp">): Promise<Result<void, AppError>> {
|
|
41
|
+
try {
|
|
42
|
+
insertStmt.run(
|
|
43
|
+
generateId(),
|
|
44
|
+
entry.userId,
|
|
45
|
+
entry.action,
|
|
46
|
+
entry.resource,
|
|
47
|
+
entry.resourceId,
|
|
48
|
+
entry.detail,
|
|
49
|
+
entry.ip,
|
|
50
|
+
Date.now(),
|
|
51
|
+
);
|
|
52
|
+
return ok(undefined);
|
|
53
|
+
} catch (e: unknown) {
|
|
54
|
+
return err(internal("Failed to append audit entry", e));
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async query(options: AuditQueryOptions): Promise<Result<readonly AuditEntry[], AppError>> {
|
|
59
|
+
try {
|
|
60
|
+
const conditions: string[] = [];
|
|
61
|
+
const params: unknown[] = [];
|
|
62
|
+
|
|
63
|
+
if (options.userId !== undefined) {
|
|
64
|
+
conditions.push("user_id = ?");
|
|
65
|
+
params.push(options.userId);
|
|
66
|
+
}
|
|
67
|
+
if (options.action !== undefined) {
|
|
68
|
+
conditions.push("action = ?");
|
|
69
|
+
params.push(options.action);
|
|
70
|
+
}
|
|
71
|
+
if (options.since !== undefined) {
|
|
72
|
+
conditions.push("timestamp >= ?");
|
|
73
|
+
params.push(options.since);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
77
|
+
const limit = options.limit ?? 50;
|
|
78
|
+
const sql = `SELECT * FROM audit_log ${where} ORDER BY timestamp DESC LIMIT ?`;
|
|
79
|
+
params.push(limit);
|
|
80
|
+
|
|
81
|
+
const rows = db
|
|
82
|
+
.query(sql)
|
|
83
|
+
.all(...(params as import("bun:sqlite").SQLQueryBindings[])) as AuditRow[];
|
|
84
|
+
return ok(rows.map(rowToEntry));
|
|
85
|
+
} catch (e: unknown) {
|
|
86
|
+
return err(internal("Failed to query audit log", e));
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type AppError, conflict, internal, notFound } from "../../core/errors/app-error.js";
|
|
3
|
+
import type { OAuthAccount, OAuthAccountRepository } from "../../core/ports/oauth.js";
|
|
4
|
+
import type { UserId } from "../../core/types/brand.js";
|
|
5
|
+
import { brand } from "../../core/types/brand.js";
|
|
6
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
7
|
+
import { generateId } from "../../shared/utils/id.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* SQLite-backed OAuth account repository.
|
|
11
|
+
* Links external OAuth provider identities to internal user accounts.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
interface OAuthRow {
|
|
15
|
+
id: string;
|
|
16
|
+
user_id: string;
|
|
17
|
+
provider: string;
|
|
18
|
+
provider_user_id: string;
|
|
19
|
+
email: string | null;
|
|
20
|
+
created_at: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const rowToAccount = (row: OAuthRow): OAuthAccount => ({
|
|
24
|
+
id: row.id,
|
|
25
|
+
userId: brand<string, "UserId">(row.user_id),
|
|
26
|
+
provider: row.provider,
|
|
27
|
+
providerUserId: row.provider_user_id,
|
|
28
|
+
email: row.email,
|
|
29
|
+
createdAt: row.created_at,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
const isUniqueViolation = (e: unknown): boolean =>
|
|
33
|
+
e instanceof Error && e.message.includes("UNIQUE");
|
|
34
|
+
|
|
35
|
+
export const createSqliteOAuthAccountRepo = (db: Database): OAuthAccountRepository => {
|
|
36
|
+
const insertStmt = db.prepare(
|
|
37
|
+
"INSERT INTO oauth_accounts (id, user_id, provider, provider_user_id, email, created_at) VALUES (?, ?, ?, ?, ?, ?)",
|
|
38
|
+
);
|
|
39
|
+
const findByProviderStmt = db.prepare<OAuthRow, [string, string]>(
|
|
40
|
+
"SELECT * FROM oauth_accounts WHERE provider = ? AND provider_user_id = ?",
|
|
41
|
+
);
|
|
42
|
+
const listByUserStmt = db.prepare<OAuthRow, [string]>(
|
|
43
|
+
"SELECT * FROM oauth_accounts WHERE user_id = ? ORDER BY created_at DESC",
|
|
44
|
+
);
|
|
45
|
+
const deleteStmt = db.prepare("DELETE FROM oauth_accounts WHERE id = ? AND user_id = ?");
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
async link(
|
|
49
|
+
userId: UserId,
|
|
50
|
+
provider: string,
|
|
51
|
+
providerUserId: string,
|
|
52
|
+
email: string | null,
|
|
53
|
+
): Promise<Result<OAuthAccount, AppError>> {
|
|
54
|
+
try {
|
|
55
|
+
const id = generateId();
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
insertStmt.run(id, userId, provider, providerUserId, email, now);
|
|
58
|
+
return ok({
|
|
59
|
+
id,
|
|
60
|
+
userId,
|
|
61
|
+
provider,
|
|
62
|
+
providerUserId,
|
|
63
|
+
email,
|
|
64
|
+
createdAt: now,
|
|
65
|
+
});
|
|
66
|
+
} catch (e: unknown) {
|
|
67
|
+
if (isUniqueViolation(e)) {
|
|
68
|
+
return err(conflict("OAuth account already linked"));
|
|
69
|
+
}
|
|
70
|
+
return err(internal("Failed to link OAuth account", e));
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
async findByProvider(
|
|
75
|
+
provider: string,
|
|
76
|
+
providerUserId: string,
|
|
77
|
+
): Promise<Result<OAuthAccount | null, AppError>> {
|
|
78
|
+
try {
|
|
79
|
+
const row = findByProviderStmt.get(provider, providerUserId);
|
|
80
|
+
return ok(row ? rowToAccount(row) : null);
|
|
81
|
+
} catch (e: unknown) {
|
|
82
|
+
return err(internal("Failed to find OAuth account", e));
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
async listByUser(userId: UserId): Promise<Result<readonly OAuthAccount[], AppError>> {
|
|
87
|
+
try {
|
|
88
|
+
const rows = listByUserStmt.all(userId);
|
|
89
|
+
return ok(rows.map(rowToAccount));
|
|
90
|
+
} catch (e: unknown) {
|
|
91
|
+
return err(internal("Failed to list OAuth accounts", e));
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
|
|
95
|
+
async unlink(id: string, userId: UserId): Promise<Result<void, AppError>> {
|
|
96
|
+
try {
|
|
97
|
+
const result = deleteStmt.run(id, userId);
|
|
98
|
+
if (result.changes === 0) return err(notFound("OAuth account"));
|
|
99
|
+
return ok(undefined);
|
|
100
|
+
} catch (e: unknown) {
|
|
101
|
+
return err(internal("Failed to unlink OAuth account", e));
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type AppError, internal } from "../../core/errors/app-error.js";
|
|
3
|
+
import type { PasswordHistory } from "../../core/ports/password-history.js";
|
|
4
|
+
import type { UserId } from "../../core/types/brand.js";
|
|
5
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
6
|
+
import { generateId } from "../../shared/utils/id.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* SQLite-backed password history — stores past password hashes
|
|
10
|
+
* to prevent reuse of recently used passwords.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const createSqlitePasswordHistory = (db: Database): PasswordHistory => {
|
|
14
|
+
const insertStmt = db.prepare(
|
|
15
|
+
"INSERT INTO password_history (id, user_id, password_hash, created_at) VALUES (?, ?, ?, ?)",
|
|
16
|
+
);
|
|
17
|
+
const getRecentStmt = db.prepare<{ password_hash: string }, [string, number]>(
|
|
18
|
+
"SELECT password_hash FROM password_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?",
|
|
19
|
+
);
|
|
20
|
+
const pruneStmt = db.prepare(
|
|
21
|
+
`DELETE FROM password_history WHERE id NOT IN (
|
|
22
|
+
SELECT id FROM password_history WHERE user_id = ? ORDER BY created_at DESC LIMIT ?
|
|
23
|
+
) AND user_id = ?`,
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
async add(userId: UserId, passwordHash: string): Promise<Result<void, AppError>> {
|
|
28
|
+
try {
|
|
29
|
+
insertStmt.run(generateId(), userId, passwordHash, Date.now());
|
|
30
|
+
return ok(undefined);
|
|
31
|
+
} catch (e: unknown) {
|
|
32
|
+
return err(internal("Failed to add password history", e));
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async getRecent(userId: UserId, count: number): Promise<Result<readonly string[], AppError>> {
|
|
37
|
+
try {
|
|
38
|
+
const rows = getRecentStmt.all(userId, count);
|
|
39
|
+
return ok(rows.map((r) => r.password_hash));
|
|
40
|
+
} catch (e: unknown) {
|
|
41
|
+
return err(internal("Failed to get password history", e));
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async prune(userId: UserId, keepCount: number): Promise<Result<void, AppError>> {
|
|
46
|
+
try {
|
|
47
|
+
pruneStmt.run(userId, keepCount, userId);
|
|
48
|
+
return ok(undefined);
|
|
49
|
+
} catch (e: unknown) {
|
|
50
|
+
return err(internal("Failed to prune password history", e));
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
};
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type AppError, internal, unauthorized } from "../../core/errors/app-error.js";
|
|
3
|
+
import type {
|
|
4
|
+
RefreshTokenFamily,
|
|
5
|
+
RefreshTokenStore,
|
|
6
|
+
} from "../../core/ports/refresh-token-store.js";
|
|
7
|
+
import type { UserId } from "../../core/types/brand.js";
|
|
8
|
+
import { brand } 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
|
+
/**
|
|
13
|
+
* SQLite-backed refresh token store with family tracking.
|
|
14
|
+
* Enables refresh token rotation with reuse detection:
|
|
15
|
+
* - Each login creates a new "family"
|
|
16
|
+
* - Each refresh rotates the token within the family
|
|
17
|
+
* - If an old token is reused → entire family is revoked (potential theft)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
interface FamilyRow {
|
|
21
|
+
id: string;
|
|
22
|
+
user_id: string;
|
|
23
|
+
current_token_hash: string;
|
|
24
|
+
revoked: number;
|
|
25
|
+
created_at: number;
|
|
26
|
+
updated_at: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const rowToFamily = (row: FamilyRow): RefreshTokenFamily => ({
|
|
30
|
+
id: row.id,
|
|
31
|
+
userId: brand<string, "UserId">(row.user_id),
|
|
32
|
+
currentTokenHash: row.current_token_hash,
|
|
33
|
+
revoked: row.revoked === 1,
|
|
34
|
+
createdAt: row.created_at,
|
|
35
|
+
updatedAt: row.updated_at,
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const createSqliteRefreshTokenStore = (db: Database): RefreshTokenStore => {
|
|
39
|
+
const insertStmt = db.prepare(
|
|
40
|
+
"INSERT INTO refresh_token_families (id, user_id, current_token_hash, revoked, created_at, updated_at) VALUES (?, ?, ?, 0, ?, ?)",
|
|
41
|
+
);
|
|
42
|
+
const findByHashStmt = db.prepare<FamilyRow, [string]>(
|
|
43
|
+
"SELECT * FROM refresh_token_families WHERE current_token_hash = ?",
|
|
44
|
+
);
|
|
45
|
+
const rotateStmt = db.prepare(
|
|
46
|
+
"UPDATE refresh_token_families SET current_token_hash = ?, updated_at = ? WHERE id = ? AND current_token_hash = ?",
|
|
47
|
+
);
|
|
48
|
+
const revokeFamilyStmt = db.prepare(
|
|
49
|
+
"UPDATE refresh_token_families SET revoked = 1, updated_at = ? WHERE id = ?",
|
|
50
|
+
);
|
|
51
|
+
const revokeAllStmt = db.prepare(
|
|
52
|
+
"UPDATE refresh_token_families SET revoked = 1, updated_at = ? WHERE user_id = ?",
|
|
53
|
+
);
|
|
54
|
+
const pruneStmt = db.prepare("DELETE FROM refresh_token_families WHERE updated_at < ?");
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
async createFamily(userId: UserId, tokenHash: string): Promise<Result<string, AppError>> {
|
|
58
|
+
try {
|
|
59
|
+
const id = generateId();
|
|
60
|
+
const now = Date.now();
|
|
61
|
+
insertStmt.run(id, userId, tokenHash, now, now);
|
|
62
|
+
return ok(id);
|
|
63
|
+
} catch (e: unknown) {
|
|
64
|
+
return err(internal("Failed to create refresh token family", e));
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
|
|
68
|
+
async rotate(
|
|
69
|
+
familyId: string,
|
|
70
|
+
oldTokenHash: string,
|
|
71
|
+
newTokenHash: string,
|
|
72
|
+
): Promise<Result<void, AppError>> {
|
|
73
|
+
try {
|
|
74
|
+
const now = Date.now();
|
|
75
|
+
const result = rotateStmt.run(newTokenHash, now, familyId, oldTokenHash);
|
|
76
|
+
if (result.changes === 0) {
|
|
77
|
+
return err(unauthorized("Token rotation failed — token mismatch"));
|
|
78
|
+
}
|
|
79
|
+
return ok(undefined);
|
|
80
|
+
} catch (e: unknown) {
|
|
81
|
+
return err(internal("Failed to rotate refresh token", e));
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
|
|
85
|
+
async findByTokenHash(tokenHash: string): Promise<Result<RefreshTokenFamily | null, AppError>> {
|
|
86
|
+
try {
|
|
87
|
+
const row = findByHashStmt.get(tokenHash);
|
|
88
|
+
return ok(row ? rowToFamily(row) : null);
|
|
89
|
+
} catch (e: unknown) {
|
|
90
|
+
return err(internal("Failed to find refresh token family", e));
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
async revokeFamily(familyId: string): Promise<Result<void, AppError>> {
|
|
95
|
+
try {
|
|
96
|
+
revokeFamilyStmt.run(Date.now(), familyId);
|
|
97
|
+
return ok(undefined);
|
|
98
|
+
} catch (e: unknown) {
|
|
99
|
+
return err(internal("Failed to revoke refresh token family", e));
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async revokeAllForUser(userId: UserId): Promise<Result<void, AppError>> {
|
|
104
|
+
try {
|
|
105
|
+
revokeAllStmt.run(Date.now(), userId);
|
|
106
|
+
return ok(undefined);
|
|
107
|
+
} catch (e: unknown) {
|
|
108
|
+
return err(internal("Failed to revoke all refresh tokens", e));
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
|
|
112
|
+
async prune(maxAgeMs: number): Promise<Result<number, AppError>> {
|
|
113
|
+
try {
|
|
114
|
+
const cutoff = Date.now() - maxAgeMs;
|
|
115
|
+
const result = pruneStmt.run(cutoff);
|
|
116
|
+
return ok(result.changes);
|
|
117
|
+
} catch (e: unknown) {
|
|
118
|
+
return err(internal("Failed to prune refresh token families", e));
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type AppError, internal } from "../../core/errors/app-error.js";
|
|
3
|
+
import type { TokenBlacklist } from "../../core/ports/token-blacklist.js";
|
|
4
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* SQLite-backed token blacklist — persists across restarts.
|
|
8
|
+
* Requires migration 002_create_token_blacklist to be applied.
|
|
9
|
+
*/
|
|
10
|
+
export const createSqliteTokenBlacklist = (db: Database): TokenBlacklist => {
|
|
11
|
+
const insertStmt = db.prepare(
|
|
12
|
+
"INSERT OR IGNORE INTO token_blacklist (token_hash, expires_at, created_at) VALUES (?, ?, ?)",
|
|
13
|
+
);
|
|
14
|
+
const checkStmt = db.prepare<{ token_hash: string }, [string, number]>(
|
|
15
|
+
"SELECT token_hash FROM token_blacklist WHERE token_hash = ? AND expires_at > ?",
|
|
16
|
+
);
|
|
17
|
+
const pruneStmt = db.prepare("DELETE FROM token_blacklist WHERE expires_at <= ?");
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
async add(tokenHash: string, expiresAt: number): Promise<Result<void, AppError>> {
|
|
21
|
+
try {
|
|
22
|
+
insertStmt.run(tokenHash, expiresAt, Date.now());
|
|
23
|
+
return ok(undefined);
|
|
24
|
+
} catch (e: unknown) {
|
|
25
|
+
return err(internal("Failed to blacklist token", e));
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
async isBlacklisted(tokenHash: string): Promise<Result<boolean, AppError>> {
|
|
30
|
+
try {
|
|
31
|
+
const row = checkStmt.get(tokenHash, Date.now());
|
|
32
|
+
return ok(row !== null);
|
|
33
|
+
} catch (e: unknown) {
|
|
34
|
+
return err(internal("Failed to check token blacklist", e));
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
async prune(): Promise<Result<number, AppError>> {
|
|
39
|
+
try {
|
|
40
|
+
const result = pruneStmt.run(Date.now());
|
|
41
|
+
return ok(result.changes);
|
|
42
|
+
} catch (e: unknown) {
|
|
43
|
+
return err(internal("Failed to prune token blacklist", e));
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
};
|