@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,260 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import type { User } from "../../core/entities/user.entity.js";
|
|
3
|
+
import type { UserRole } from "../../core/entities/user.entity.js";
|
|
4
|
+
import { type AppError, conflict, internal, notFound } from "../../core/errors/app-error.js";
|
|
5
|
+
import type {
|
|
6
|
+
CreateUserData,
|
|
7
|
+
UpdateUserData,
|
|
8
|
+
UserListOptions,
|
|
9
|
+
UserRepository,
|
|
10
|
+
} from "../../core/ports/user.repository.js";
|
|
11
|
+
import type { UserId } from "../../core/types/brand.js";
|
|
12
|
+
import { brand } from "../../core/types/brand.js";
|
|
13
|
+
import { decodeCursor, encodeCursor } from "../../core/types/pagination.js";
|
|
14
|
+
import { type Result, err, ok } from "../../core/types/result.js";
|
|
15
|
+
import { generateId } from "../../shared/utils/id.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* SQLite user repository — zero external dependencies (bun:sqlite is built-in).
|
|
19
|
+
* Production-ready persistence that swaps cleanly for the in-memory adapter.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
interface UserRow {
|
|
23
|
+
id: string;
|
|
24
|
+
email: string;
|
|
25
|
+
password_hash: string;
|
|
26
|
+
role: string;
|
|
27
|
+
email_verified: number;
|
|
28
|
+
mfa_secret: string | null;
|
|
29
|
+
mfa_enabled: number;
|
|
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 === 1,
|
|
43
|
+
mfaEnabled: row.mfa_enabled === 1,
|
|
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
|
+
/** Build SET clause entries from partial update data */
|
|
52
|
+
const buildUpdateFields = (data: UpdateUserData, now: number): [string, unknown][] => {
|
|
53
|
+
const fields: [string, unknown][] = [];
|
|
54
|
+
if (data.email !== undefined) fields.push(["email = ?", data.email]);
|
|
55
|
+
if (data.passwordHash !== undefined) fields.push(["password_hash = ?", data.passwordHash]);
|
|
56
|
+
if (data.role !== undefined) fields.push(["role = ?", data.role]);
|
|
57
|
+
if (data.emailVerified !== undefined)
|
|
58
|
+
fields.push(["email_verified = ?", data.emailVerified ? 1 : 0]);
|
|
59
|
+
if (data.mfaEnabled !== undefined) fields.push(["mfa_enabled = ?", data.mfaEnabled ? 1 : 0]);
|
|
60
|
+
if (data.mfaSecret !== undefined) fields.push(["mfa_secret = ?", data.mfaSecret]);
|
|
61
|
+
if (data.passwordChangedAt !== undefined)
|
|
62
|
+
fields.push(["password_changed_at = ?", data.passwordChangedAt]);
|
|
63
|
+
fields.push(["updated_at = ?", now]);
|
|
64
|
+
return fields;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Detect SQLite UNIQUE constraint violations */
|
|
68
|
+
const isUniqueViolation = (e: unknown): boolean =>
|
|
69
|
+
e instanceof Error && e.message.includes("UNIQUE");
|
|
70
|
+
|
|
71
|
+
export const createSqliteUserRepository = (db: Database): UserRepository => {
|
|
72
|
+
// Pre-compile queries for performance
|
|
73
|
+
const findByIdStmt = db.prepare<UserRow, [string]>("SELECT * FROM users WHERE id = ?");
|
|
74
|
+
const findByEmailStmt = db.prepare<UserRow, [string]>("SELECT * FROM users WHERE email = ?");
|
|
75
|
+
const insertStmt = db.prepare(
|
|
76
|
+
"INSERT INTO users (id, email, password_hash, role, email_verified, mfa_enabled, failed_login_attempts, locked_until, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 0, 0, NULL, ?, ?)",
|
|
77
|
+
);
|
|
78
|
+
const deleteStmt = db.prepare("DELETE FROM users WHERE id = ?");
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
async findById(id: UserId): Promise<Result<User, AppError>> {
|
|
82
|
+
try {
|
|
83
|
+
const row = findByIdStmt.get(id);
|
|
84
|
+
if (!row) return err(notFound("User"));
|
|
85
|
+
return ok(rowToUser(row));
|
|
86
|
+
} catch (e: unknown) {
|
|
87
|
+
return err(internal("Database error", e));
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async findByEmail(email: string): Promise<Result<User, AppError>> {
|
|
92
|
+
try {
|
|
93
|
+
const row = findByEmailStmt.get(email);
|
|
94
|
+
if (!row) return err(notFound("User"));
|
|
95
|
+
return ok(rowToUser(row));
|
|
96
|
+
} catch (e: unknown) {
|
|
97
|
+
return err(internal("Database error", e));
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
async create(data: CreateUserData): Promise<Result<User, AppError>> {
|
|
102
|
+
try {
|
|
103
|
+
const id = generateId();
|
|
104
|
+
const now = Date.now();
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
insertStmt.run(id, data.email, data.passwordHash, data.role, now, now);
|
|
108
|
+
} catch (e: unknown) {
|
|
109
|
+
if (isUniqueViolation(e)) return err(conflict("Email already exists"));
|
|
110
|
+
throw e;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const user: User = {
|
|
114
|
+
id: brand<string, "UserId">(id),
|
|
115
|
+
email: data.email,
|
|
116
|
+
passwordHash: data.passwordHash,
|
|
117
|
+
role: data.role,
|
|
118
|
+
emailVerified: false,
|
|
119
|
+
mfaEnabled: false,
|
|
120
|
+
mfaSecret: null,
|
|
121
|
+
passwordChangedAt: null,
|
|
122
|
+
createdAt: brand<number, "Timestamp">(now),
|
|
123
|
+
updatedAt: brand<number, "Timestamp">(now),
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
return ok(user);
|
|
127
|
+
} catch (e: unknown) {
|
|
128
|
+
return err(internal("Database error", e));
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
async update(id: UserId, data: UpdateUserData): Promise<Result<User, AppError>> {
|
|
133
|
+
try {
|
|
134
|
+
const existing = findByIdStmt.get(id);
|
|
135
|
+
if (!existing) return err(notFound("User"));
|
|
136
|
+
|
|
137
|
+
const fieldMap = buildUpdateFields(data, Date.now());
|
|
138
|
+
const sql = `UPDATE users SET ${fieldMap.map(([f]) => f).join(", ")} WHERE id = ?`;
|
|
139
|
+
const values = [...fieldMap.map(([, v]) => v), id];
|
|
140
|
+
db.run(sql, values as import("bun:sqlite").SQLQueryBindings[]);
|
|
141
|
+
|
|
142
|
+
const updated = findByIdStmt.get(id);
|
|
143
|
+
if (!updated) return err(internal("User disappeared after update"));
|
|
144
|
+
return ok(rowToUser(updated));
|
|
145
|
+
} catch (e: unknown) {
|
|
146
|
+
if (isUniqueViolation(e)) return err(conflict("Email already exists"));
|
|
147
|
+
return err(internal("Database error", e));
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
async delete(id: UserId): Promise<Result<void, AppError>> {
|
|
152
|
+
try {
|
|
153
|
+
const existing = findByIdStmt.get(id);
|
|
154
|
+
if (!existing) return err(notFound("User"));
|
|
155
|
+
|
|
156
|
+
deleteStmt.run(id);
|
|
157
|
+
return ok(undefined);
|
|
158
|
+
} catch (e: unknown) {
|
|
159
|
+
return err(internal("Database error", e));
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SQL builder with cursor/filter/search needs branching
|
|
164
|
+
async list(options: UserListOptions) {
|
|
165
|
+
try {
|
|
166
|
+
const conditions: string[] = [];
|
|
167
|
+
const params: unknown[] = [];
|
|
168
|
+
|
|
169
|
+
// Cursor-based pagination: cursor encodes created_at timestamp
|
|
170
|
+
if (options.cursor !== undefined) {
|
|
171
|
+
const decoded = decodeCursor(options.cursor);
|
|
172
|
+
if (decoded !== null) {
|
|
173
|
+
conditions.push("created_at < ?");
|
|
174
|
+
params.push(Number(decoded));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Filter by role
|
|
179
|
+
if (options.role !== undefined) {
|
|
180
|
+
conditions.push("role = ?");
|
|
181
|
+
params.push(options.role);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Search by email (prefix match)
|
|
185
|
+
if (options.search !== undefined) {
|
|
186
|
+
conditions.push("email LIKE ?");
|
|
187
|
+
params.push(`%${options.search}%`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
|
|
191
|
+
const limit = Math.min(options.limit, 100);
|
|
192
|
+
|
|
193
|
+
// Fetch one extra to know if there are more results
|
|
194
|
+
const sql = `SELECT * FROM users ${where} ORDER BY created_at DESC LIMIT ?`;
|
|
195
|
+
params.push(limit + 1);
|
|
196
|
+
|
|
197
|
+
const rows = db
|
|
198
|
+
.query(sql)
|
|
199
|
+
.all(...(params as import("bun:sqlite").SQLQueryBindings[])) as UserRow[];
|
|
200
|
+
|
|
201
|
+
const hasMore = rows.length > limit;
|
|
202
|
+
const items = (hasMore ? rows.slice(0, limit) : rows).map(rowToUser);
|
|
203
|
+
|
|
204
|
+
const lastItem = items[items.length - 1];
|
|
205
|
+
const nextCursor =
|
|
206
|
+
hasMore && lastItem !== undefined ? encodeCursor(String(lastItem.createdAt)) : null;
|
|
207
|
+
|
|
208
|
+
return ok({ items, nextCursor, hasMore });
|
|
209
|
+
} catch (e: unknown) {
|
|
210
|
+
return err(internal("Database error", e));
|
|
211
|
+
}
|
|
212
|
+
},
|
|
213
|
+
|
|
214
|
+
async count() {
|
|
215
|
+
try {
|
|
216
|
+
const row = db.query("SELECT COUNT(*) as cnt FROM users").get() as { cnt: number };
|
|
217
|
+
return ok(row.cnt);
|
|
218
|
+
} catch (e: unknown) {
|
|
219
|
+
return err(internal("Database error", e));
|
|
220
|
+
}
|
|
221
|
+
},
|
|
222
|
+
};
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Account lockout helpers — query/update failed login attempts directly.
|
|
227
|
+
*/
|
|
228
|
+
export const createAccountLockoutQueries = (db: Database) => {
|
|
229
|
+
const getAttemptsStmt = db.prepare<
|
|
230
|
+
{ failed_login_attempts: number; locked_until: number | null },
|
|
231
|
+
[string]
|
|
232
|
+
>("SELECT failed_login_attempts, locked_until FROM users WHERE email = ?");
|
|
233
|
+
|
|
234
|
+
const incrementFailedStmt = db.prepare(
|
|
235
|
+
"UPDATE users SET failed_login_attempts = failed_login_attempts + 1 WHERE email = ?",
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const lockAccountStmt = db.prepare(
|
|
239
|
+
"UPDATE users SET locked_until = ?, failed_login_attempts = ? WHERE email = ?",
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const resetAttemptsStmt = db.prepare(
|
|
243
|
+
"UPDATE users SET failed_login_attempts = 0, locked_until = NULL WHERE email = ?",
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
getAttempts(email: string) {
|
|
248
|
+
return getAttemptsStmt.get(email);
|
|
249
|
+
},
|
|
250
|
+
incrementFailed(email: string) {
|
|
251
|
+
incrementFailedStmt.run(email);
|
|
252
|
+
},
|
|
253
|
+
lockAccount(email: string, until: number, attempts: number) {
|
|
254
|
+
lockAccountStmt.run(until, attempts, email);
|
|
255
|
+
},
|
|
256
|
+
resetAttempts(email: string) {
|
|
257
|
+
resetAttemptsStmt.run(email);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { type AppError, internal, unauthorized } from "../../core/errors/app-error.js";
|
|
3
|
+
import type {
|
|
4
|
+
VerificationTokenRepository,
|
|
5
|
+
VerificationTokenType,
|
|
6
|
+
} from "../../core/ports/verification-token.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 verification token repository.
|
|
14
|
+
* Tokens are SHA-256 hashed before storage — raw tokens are never persisted.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const hashToken = async (raw: string): Promise<string> => {
|
|
18
|
+
const data = new TextEncoder().encode(raw);
|
|
19
|
+
const buf = await crypto.subtle.digest("SHA-256", data);
|
|
20
|
+
return Array.from(new Uint8Array(buf))
|
|
21
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
22
|
+
.join("");
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const generateRawToken = (): string => {
|
|
26
|
+
const bytes = new Uint8Array(32);
|
|
27
|
+
crypto.getRandomValues(bytes);
|
|
28
|
+
return Array.from(bytes)
|
|
29
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
30
|
+
.join("");
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface TokenRow {
|
|
34
|
+
id: string;
|
|
35
|
+
user_id: string;
|
|
36
|
+
type: string;
|
|
37
|
+
token_hash: string;
|
|
38
|
+
expires_at: number;
|
|
39
|
+
used_at: number | null;
|
|
40
|
+
created_at: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export const createSqliteVerificationTokenRepo = (db: Database): VerificationTokenRepository => {
|
|
44
|
+
const insertStmt = db.prepare(
|
|
45
|
+
"INSERT INTO verification_tokens (id, user_id, type, token_hash, expires_at, used_at, created_at) VALUES (?, ?, ?, ?, ?, NULL, ?)",
|
|
46
|
+
);
|
|
47
|
+
const findByHashStmt = db.prepare<TokenRow, [string, string]>(
|
|
48
|
+
"SELECT * FROM verification_tokens WHERE token_hash = ? AND type = ?",
|
|
49
|
+
);
|
|
50
|
+
const markUsedStmt = db.prepare("UPDATE verification_tokens SET used_at = ? WHERE id = ?");
|
|
51
|
+
const invalidateStmt = db.prepare(
|
|
52
|
+
"DELETE FROM verification_tokens WHERE user_id = ? AND type = ?",
|
|
53
|
+
);
|
|
54
|
+
const pruneStmt = db.prepare(
|
|
55
|
+
"DELETE FROM verification_tokens WHERE expires_at <= ? OR used_at IS NOT NULL",
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
async create(
|
|
60
|
+
userId: UserId,
|
|
61
|
+
type: VerificationTokenType,
|
|
62
|
+
ttlMs: number,
|
|
63
|
+
): Promise<Result<string, AppError>> {
|
|
64
|
+
try {
|
|
65
|
+
const rawToken = generateRawToken();
|
|
66
|
+
const tokenHash = await hashToken(rawToken);
|
|
67
|
+
const id = generateId();
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
insertStmt.run(id, userId, type, tokenHash, now + ttlMs, now);
|
|
70
|
+
return ok(rawToken);
|
|
71
|
+
} catch (e: unknown) {
|
|
72
|
+
return err(internal("Failed to create verification token", e));
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async verify(rawToken: string, type: VerificationTokenType): Promise<Result<UserId, AppError>> {
|
|
77
|
+
try {
|
|
78
|
+
const tokenHash = await hashToken(rawToken);
|
|
79
|
+
const row = findByHashStmt.get(tokenHash, type);
|
|
80
|
+
if (!row) return err(unauthorized("Invalid or expired token"));
|
|
81
|
+
if (row.used_at !== null) return err(unauthorized("Token already used"));
|
|
82
|
+
if (row.expires_at <= Date.now()) return err(unauthorized("Token has expired"));
|
|
83
|
+
|
|
84
|
+
markUsedStmt.run(Date.now(), row.id);
|
|
85
|
+
return ok(brand<string, "UserId">(row.user_id));
|
|
86
|
+
} catch (e: unknown) {
|
|
87
|
+
return err(internal("Failed to verify token", e));
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
async invalidateAll(
|
|
92
|
+
userId: UserId,
|
|
93
|
+
type: VerificationTokenType,
|
|
94
|
+
): Promise<Result<void, AppError>> {
|
|
95
|
+
try {
|
|
96
|
+
invalidateStmt.run(userId, type);
|
|
97
|
+
return ok(undefined);
|
|
98
|
+
} catch (e: unknown) {
|
|
99
|
+
return err(internal("Failed to invalidate tokens", e));
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
async prune(): Promise<Result<number, AppError>> {
|
|
104
|
+
try {
|
|
105
|
+
const result = pruneStmt.run(Date.now());
|
|
106
|
+
return ok(result.changes);
|
|
107
|
+
} catch (e: unknown) {
|
|
108
|
+
return err(internal("Failed to prune verification tokens", e));
|
|
109
|
+
}
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory event bus implementation.
|
|
3
|
+
*
|
|
4
|
+
* Synchronous publish with async handler execution (fire-and-forget).
|
|
5
|
+
* Handlers that throw are caught and logged — never crash the publisher.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type {
|
|
9
|
+
DomainEvent,
|
|
10
|
+
DomainEventType,
|
|
11
|
+
EventBus,
|
|
12
|
+
EventHandler,
|
|
13
|
+
} from "../../core/ports/event-bus.js";
|
|
14
|
+
import type { Logger } from "../../core/ports/logger.js";
|
|
15
|
+
|
|
16
|
+
interface EventBusDeps {
|
|
17
|
+
readonly logger: Logger;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const createEventBus = (deps: EventBusDeps): EventBus => {
|
|
21
|
+
const { logger } = deps;
|
|
22
|
+
|
|
23
|
+
/** type → Set<handler> */
|
|
24
|
+
const handlers = new Map<DomainEventType, Set<EventHandler>>();
|
|
25
|
+
/** Wildcard handlers that receive ALL events */
|
|
26
|
+
const wildcardHandlers = new Set<EventHandler>();
|
|
27
|
+
|
|
28
|
+
const getOrCreate = (type: DomainEventType): Set<EventHandler> => {
|
|
29
|
+
let set = handlers.get(type);
|
|
30
|
+
if (!set) {
|
|
31
|
+
set = new Set();
|
|
32
|
+
handlers.set(type, set);
|
|
33
|
+
}
|
|
34
|
+
return set;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const invokeHandler = (handler: EventHandler, event: DomainEvent): void => {
|
|
38
|
+
try {
|
|
39
|
+
const result = handler(event);
|
|
40
|
+
// If handler returns a Promise, catch async errors
|
|
41
|
+
if (result && typeof result === "object" && "catch" in result) {
|
|
42
|
+
(result as Promise<void>).catch((err: unknown) => {
|
|
43
|
+
logger.error("Async event handler error", {
|
|
44
|
+
event: event.type,
|
|
45
|
+
eventId: event.id,
|
|
46
|
+
error: err instanceof Error ? err.message : String(err),
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
} catch (err: unknown) {
|
|
51
|
+
logger.error("Sync event handler error", {
|
|
52
|
+
event: event.type,
|
|
53
|
+
eventId: event.id,
|
|
54
|
+
error: err instanceof Error ? err.message : String(err),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
publish(event: DomainEvent): void {
|
|
61
|
+
logger.debug("Event published", { type: event.type, eventId: event.id });
|
|
62
|
+
|
|
63
|
+
// Type-specific handlers
|
|
64
|
+
const typeHandlers = handlers.get(event.type);
|
|
65
|
+
if (typeHandlers) {
|
|
66
|
+
for (const handler of typeHandlers) {
|
|
67
|
+
invokeHandler(handler, event);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Wildcard handlers
|
|
72
|
+
for (const handler of wildcardHandlers) {
|
|
73
|
+
invokeHandler(handler, event);
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
subscribe(type: DomainEventType, handler: EventHandler): () => void {
|
|
78
|
+
const set = getOrCreate(type);
|
|
79
|
+
set.add(handler);
|
|
80
|
+
logger.debug("Event handler subscribed", { type, handlerCount: set.size });
|
|
81
|
+
|
|
82
|
+
return () => {
|
|
83
|
+
set.delete(handler);
|
|
84
|
+
if (set.size === 0) handlers.delete(type);
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
subscribeAll(handler: EventHandler): () => void {
|
|
89
|
+
wildcardHandlers.add(handler);
|
|
90
|
+
logger.debug("Wildcard event handler subscribed", { handlerCount: wildcardHandlers.size });
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
wildcardHandlers.delete(handler);
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
get handlerCount(): number {
|
|
98
|
+
let count = wildcardHandlers.size;
|
|
99
|
+
for (const set of handlers.values()) {
|
|
100
|
+
count += set.size;
|
|
101
|
+
}
|
|
102
|
+
return count;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Domain event factory — creates properly structured DomainEvent instances.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { DomainEvent, DomainEventType } from "../../core/ports/event-bus.js";
|
|
6
|
+
import type { UserId } from "../../core/types/brand.js";
|
|
7
|
+
import { generateId } from "../../shared/utils/id.js";
|
|
8
|
+
|
|
9
|
+
interface CreateEventOptions {
|
|
10
|
+
readonly type: DomainEventType;
|
|
11
|
+
readonly userId?: UserId | undefined;
|
|
12
|
+
readonly payload?: Readonly<Record<string, unknown>> | undefined;
|
|
13
|
+
readonly ip?: string | undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface DomainEventFactory {
|
|
17
|
+
create(options: CreateEventOptions): DomainEvent;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const createDomainEventFactory = (): DomainEventFactory => ({
|
|
21
|
+
create(options: CreateEventOptions): DomainEvent {
|
|
22
|
+
return {
|
|
23
|
+
id: generateId(),
|
|
24
|
+
type: options.type,
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
...(options.userId ? { userId: options.userId } : {}),
|
|
27
|
+
payload: options.payload ?? {},
|
|
28
|
+
...(options.ip ? { ip: options.ip } : {}),
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory webhook registry — stores webhook subscriptions in a Map.
|
|
3
|
+
*
|
|
4
|
+
* For production use, swap with a SQLite/Redis-backed adapter.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AppError } from "../../core/errors/app-error.js";
|
|
8
|
+
import { notFound } from "../../core/errors/app-error.js";
|
|
9
|
+
import type { DomainEventType } from "../../core/ports/event-bus.js";
|
|
10
|
+
import type {
|
|
11
|
+
CreateWebhookData,
|
|
12
|
+
WebhookDelivery,
|
|
13
|
+
WebhookRegistry,
|
|
14
|
+
WebhookSubscription,
|
|
15
|
+
} from "../../core/ports/webhook.js";
|
|
16
|
+
import type { Result } from "../../core/types/result.js";
|
|
17
|
+
import { err, ok } from "../../core/types/result.js";
|
|
18
|
+
import { generateId } from "../../shared/utils/id.js";
|
|
19
|
+
|
|
20
|
+
export const createInMemoryWebhookRegistry = (): WebhookRegistry => {
|
|
21
|
+
const store = new Map<string, WebhookSubscription>();
|
|
22
|
+
const deliveries: WebhookDelivery[] = [];
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
create(data: CreateWebhookData): Result<WebhookSubscription, AppError> {
|
|
26
|
+
const sub: WebhookSubscription = {
|
|
27
|
+
id: generateId(),
|
|
28
|
+
url: data.url,
|
|
29
|
+
events: [...data.events],
|
|
30
|
+
secret: data.secret,
|
|
31
|
+
active: true,
|
|
32
|
+
createdAt: new Date().toISOString(),
|
|
33
|
+
};
|
|
34
|
+
store.set(sub.id, sub);
|
|
35
|
+
return ok(sub);
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
list(): Result<ReadonlyArray<WebhookSubscription>, AppError> {
|
|
39
|
+
return ok([...store.values()]);
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
findByEvent(eventType: DomainEventType): Result<ReadonlyArray<WebhookSubscription>, AppError> {
|
|
43
|
+
const matching = [...store.values()].filter(
|
|
44
|
+
(sub) => sub.active && (sub.events.length === 0 || sub.events.includes(eventType)),
|
|
45
|
+
);
|
|
46
|
+
return ok(matching);
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
remove(id: string): Result<void, AppError> {
|
|
50
|
+
if (!store.has(id)) return err(notFound("Webhook subscription"));
|
|
51
|
+
store.delete(id);
|
|
52
|
+
return ok(undefined);
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
setActive(id: string, active: boolean): Result<void, AppError> {
|
|
56
|
+
const sub = store.get(id);
|
|
57
|
+
if (!sub) return err(notFound("Webhook subscription"));
|
|
58
|
+
store.set(id, { ...sub, active });
|
|
59
|
+
return ok(undefined);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
recordDelivery(delivery: WebhookDelivery): Result<void, AppError> {
|
|
63
|
+
deliveries.push(delivery);
|
|
64
|
+
return ok(undefined);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook dispatcher — delivers domain events to registered webhook URLs.
|
|
3
|
+
*
|
|
4
|
+
* Features:
|
|
5
|
+
* - HMAC-SHA256 signature in X-Webhook-Signature header
|
|
6
|
+
* - Retry with exponential backoff (via job queue)
|
|
7
|
+
* - Delivery recording for audit trail
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DomainEvent } from "../../core/ports/event-bus.js";
|
|
11
|
+
import type { Logger } from "../../core/ports/logger.js";
|
|
12
|
+
import type { WebhookRegistry, WebhookSubscription } from "../../core/ports/webhook.js";
|
|
13
|
+
import { generateId } from "../../shared/utils/id.js";
|
|
14
|
+
|
|
15
|
+
interface WebhookDispatcherDeps {
|
|
16
|
+
readonly registry: WebhookRegistry;
|
|
17
|
+
readonly logger: Logger;
|
|
18
|
+
readonly timeoutMs?: number | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface WebhookDispatcher {
|
|
22
|
+
/** Dispatch an event to all matching webhook subscriptions */
|
|
23
|
+
dispatch(event: DomainEvent): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const signPayload = async (payload: string, secret: string): Promise<string> => {
|
|
27
|
+
const encoder = new TextEncoder();
|
|
28
|
+
const key = await crypto.subtle.importKey(
|
|
29
|
+
"raw",
|
|
30
|
+
encoder.encode(secret),
|
|
31
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
32
|
+
false,
|
|
33
|
+
["sign"],
|
|
34
|
+
);
|
|
35
|
+
const sig = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));
|
|
36
|
+
return Array.from(new Uint8Array(sig))
|
|
37
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
38
|
+
.join("");
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const deliverWebhook = async (
|
|
42
|
+
sub: WebhookSubscription,
|
|
43
|
+
event: DomainEvent,
|
|
44
|
+
logger: Logger,
|
|
45
|
+
timeoutMs: number,
|
|
46
|
+
): Promise<void> => {
|
|
47
|
+
const body = JSON.stringify({
|
|
48
|
+
id: event.id,
|
|
49
|
+
type: event.type,
|
|
50
|
+
timestamp: event.timestamp,
|
|
51
|
+
payload: event.payload,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const signature = await signPayload(body, sub.secret);
|
|
55
|
+
const deliveryId = generateId();
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
60
|
+
|
|
61
|
+
const res = await fetch(sub.url, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: {
|
|
64
|
+
"Content-Type": "application/json",
|
|
65
|
+
"X-Webhook-Id": sub.id,
|
|
66
|
+
"X-Webhook-Delivery": deliveryId,
|
|
67
|
+
"X-Webhook-Signature": `sha256=${signature}`,
|
|
68
|
+
"X-Webhook-Event": event.type,
|
|
69
|
+
},
|
|
70
|
+
body,
|
|
71
|
+
signal: controller.signal,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
|
|
76
|
+
logger.debug("Webhook delivered", {
|
|
77
|
+
webhookId: sub.id,
|
|
78
|
+
deliveryId,
|
|
79
|
+
url: sub.url,
|
|
80
|
+
status: res.status,
|
|
81
|
+
success: res.ok,
|
|
82
|
+
});
|
|
83
|
+
} catch (err: unknown) {
|
|
84
|
+
logger.warn("Webhook delivery failed", {
|
|
85
|
+
webhookId: sub.id,
|
|
86
|
+
deliveryId,
|
|
87
|
+
url: sub.url,
|
|
88
|
+
error: err instanceof Error ? err.message : String(err),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const createWebhookDispatcher = (deps: WebhookDispatcherDeps): WebhookDispatcher => {
|
|
94
|
+
const { registry, logger } = deps;
|
|
95
|
+
const timeoutMs = deps.timeoutMs ?? 5_000;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
dispatch(event: DomainEvent): void {
|
|
99
|
+
const subsResult = registry.findByEvent(event.type);
|
|
100
|
+
if (!subsResult.ok) {
|
|
101
|
+
logger.error("Failed to find webhook subscriptions", {
|
|
102
|
+
event: event.type,
|
|
103
|
+
error: subsResult.error.message,
|
|
104
|
+
});
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const sub of subsResult.value) {
|
|
109
|
+
// Fire-and-forget — errors caught inside
|
|
110
|
+
deliverWebhook(sub, event, logger, timeoutMs);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
};
|