@hachej/boring-core 0.1.0

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +83 -0
  3. package/dist/CoreFront-CDeLdfb0.d.ts +19 -0
  4. package/dist/app/front/index.d.ts +18 -0
  5. package/dist/app/front/index.js +162 -0
  6. package/dist/app/front/styles.css +6 -0
  7. package/dist/app/server/index.d.ts +96 -0
  8. package/dist/app/server/index.js +507 -0
  9. package/dist/app/vite/index.d.ts +10 -0
  10. package/dist/app/vite/index.js +33 -0
  11. package/dist/authHook-vsRhOvnh.d.ts +38 -0
  12. package/dist/chunk-CZ4HIXII.js +2869 -0
  13. package/dist/chunk-H5KU6R6Y.js +68 -0
  14. package/dist/chunk-HSRBZLKT.js +1684 -0
  15. package/dist/chunk-HYNKZSTF.js +18 -0
  16. package/dist/chunk-MLKGABMK.js +9 -0
  17. package/dist/chunk-VTOS4C7B.js +3443 -0
  18. package/dist/connection-CE7z-wBp.d.ts +145 -0
  19. package/dist/front/index.d.ts +458 -0
  20. package/dist/front/index.js +126 -0
  21. package/dist/front/theme.css +168 -0
  22. package/dist/front/top-bar-slot.d.ts +10 -0
  23. package/dist/front/top-bar-slot.js +9 -0
  24. package/dist/index-COZa03RP.d.ts +266 -0
  25. package/dist/migrate-D49JsATX.d.ts +8 -0
  26. package/dist/server/db/index.d.ts +209 -0
  27. package/dist/server/db/index.js +18 -0
  28. package/dist/server/index.d.ts +395 -0
  29. package/dist/server/index.js +136 -0
  30. package/dist/shared/index.d.ts +1 -0
  31. package/dist/shared/index.js +13 -0
  32. package/drizzle/.gitkeep +0 -0
  33. package/drizzle/0000_easy_meggan.sql +53 -0
  34. package/drizzle/0001_groovy_smiling_tiger.sql +14 -0
  35. package/drizzle/0002_busy_iron_man.sql +16 -0
  36. package/drizzle/0003_aspiring_richard_fisk.sql +12 -0
  37. package/drizzle/0004_heavy_lenny_balinger.sql +9 -0
  38. package/drizzle/0005_flimsy_mastermind.sql +17 -0
  39. package/drizzle/0006_happy_callisto.sql +13 -0
  40. package/drizzle/0007_v7_substrate.sql +54 -0
  41. package/drizzle/0008_workspace_sandbox_handles.sql +32 -0
  42. package/drizzle/0009_workspace_runtime_resources.sql +39 -0
  43. package/drizzle/meta/0000_snapshot.json +380 -0
  44. package/drizzle/meta/0001_snapshot.json +471 -0
  45. package/drizzle/meta/0002_snapshot.json +599 -0
  46. package/drizzle/meta/0003_snapshot.json +693 -0
  47. package/drizzle/meta/0004_snapshot.json +753 -0
  48. package/drizzle/meta/0005_snapshot.json +886 -0
  49. package/drizzle/meta/0006_snapshot.json +968 -0
  50. package/drizzle/meta/_journal.json +76 -0
  51. package/drizzle/schema.ts +110 -0
  52. package/package.json +127 -0
@@ -0,0 +1,2869 @@
1
+ import {
2
+ idempotencyKeys,
3
+ schema_exports,
4
+ users,
5
+ verification_tokens,
6
+ workspaceInvites,
7
+ workspaceMembers,
8
+ workspaceRuntimes,
9
+ workspaceSettings,
10
+ workspaces
11
+ } from "./chunk-HSRBZLKT.js";
12
+ import {
13
+ ConfigValidationError,
14
+ ERROR_CODES,
15
+ HttpError
16
+ } from "./chunk-H5KU6R6Y.js";
17
+
18
+ // src/server/config/schema.ts
19
+ import { z } from "zod";
20
+ var VALID_MAIL_SCHEMES = ["resend://", "smtp://", "smtps://", "console://", "console-capture://"];
21
+ var logLevelSchema = z.enum([
22
+ "fatal",
23
+ "error",
24
+ "warn",
25
+ "info",
26
+ "debug",
27
+ "trace"
28
+ ]);
29
+ var rateLimitEndpointOverrideSchema = z.object({
30
+ max: z.number().int().positive(),
31
+ window: z.string().min(1)
32
+ });
33
+ var mailTransportUrlSchema = z.string().refine(
34
+ (url) => VALID_MAIL_SCHEMES.some((s) => url.startsWith(s)),
35
+ {
36
+ message: `Mail transport URL must start with one of: ${VALID_MAIL_SCHEMES.join(", ")}`
37
+ }
38
+ );
39
+ var coreConfigSchema = z.object({
40
+ appId: z.string().min(1),
41
+ appName: z.string().min(1),
42
+ appLogo: z.string().nullable(),
43
+ port: z.number().int().min(1).max(65535),
44
+ host: z.string().min(1),
45
+ staticDir: z.string().nullable(),
46
+ databaseUrl: z.string().nullable(),
47
+ stores: z.enum(["postgres", "local"]),
48
+ cors: z.object({
49
+ origins: z.array(z.string()),
50
+ credentials: z.literal(true)
51
+ }),
52
+ bodyLimit: z.number().int().positive(),
53
+ logLevel: logLevelSchema,
54
+ rateLimit: z.record(rateLimitEndpointOverrideSchema).optional(),
55
+ security: z.object({
56
+ csp: z.object({
57
+ enabled: z.boolean(),
58
+ upgradeInsecureRequests: z.boolean().optional()
59
+ })
60
+ }).optional(),
61
+ encryption: z.object({
62
+ workspaceSettingsKey: z.string().min(1)
63
+ }),
64
+ auth: z.object({
65
+ secret: z.string().min(1),
66
+ url: z.string().url(),
67
+ github: z.object({
68
+ clientId: z.string().min(1),
69
+ clientSecret: z.string().min(1)
70
+ }).optional(),
71
+ mail: z.object({
72
+ from: z.string().min(1),
73
+ transportUrl: mailTransportUrlSchema
74
+ }).optional(),
75
+ sessionTtlSeconds: z.number().int().positive(),
76
+ sessionCookieSecure: z.boolean()
77
+ }),
78
+ features: z.object({
79
+ githubOauth: z.boolean(),
80
+ invitesEnabled: z.boolean(),
81
+ sendWelcomeEmail: z.boolean(),
82
+ inviteTtlDays: z.number().int().min(1).max(30).default(7)
83
+ })
84
+ });
85
+
86
+ // src/server/config/loadConfig.ts
87
+ import { readFileSync, existsSync } from "fs";
88
+ import { resolve } from "path";
89
+ import { parse as parseTOML } from "smol-toml";
90
+ var THIRTY_DAYS_SECONDS = 60 * 60 * 24 * 30;
91
+ var SIXTEEN_MB = 16 * 1024 * 1024;
92
+ var INSECURE_PLACEHOLDER_SECRET = "0000000000000000000000000000000000000000000000000000000000000000";
93
+ var INSECURE_DATABASE_URL = "postgres://placeholder:placeholder@localhost:5432/placeholder";
94
+ var INSECURE_ENCRYPTION_KEY = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
95
+ function parseRateLimitOverrides(raw) {
96
+ if (!raw) return void 0;
97
+ try {
98
+ return JSON.parse(raw);
99
+ } catch {
100
+ throw new ConfigValidationError([
101
+ {
102
+ message: 'RATE_LIMIT_OVERRIDES_JSON must be valid JSON object: {"<endpoint>":{"max":number,"window":"<duration>"}}',
103
+ path: ["rateLimit"]
104
+ }
105
+ ]);
106
+ }
107
+ }
108
+ async function loadConfig(options) {
109
+ const env = options?.env ?? process.env;
110
+ const tomlPath = resolve(options?.tomlPath ?? "./boring.app.toml");
111
+ const allowMissingSecrets = options?.allowMissingSecrets ?? false;
112
+ if (allowMissingSecrets && env.NODE_ENV === "production") {
113
+ throw new ConfigValidationError([
114
+ {
115
+ message: "allowMissingSecrets is forbidden in production",
116
+ path: ["allowMissingSecrets"]
117
+ }
118
+ ]);
119
+ }
120
+ let toml = {};
121
+ if (existsSync(tomlPath)) {
122
+ const raw2 = readFileSync(tomlPath, "utf-8");
123
+ toml = parseTOML(raw2);
124
+ }
125
+ const appId = toml.app?.id ?? env.APP_ID ?? "boring-app";
126
+ const appName = toml.frontend?.branding?.name ?? env.APP_NAME ?? appId;
127
+ const appLogo = toml.frontend?.branding?.logo ?? null;
128
+ const storesRaw = env.CORE_STORES ?? "postgres";
129
+ const stores = storesRaw === "local" ? "local" : "postgres";
130
+ let databaseUrl = env.DATABASE_URL ?? null;
131
+ let authSecret = env.BETTER_AUTH_SECRET ?? "";
132
+ let encryptionKey = env.WORKSPACE_SETTINGS_ENCRYPTION_KEY ?? "";
133
+ const insecureDefaults = [];
134
+ if (allowMissingSecrets) {
135
+ if (!databaseUrl) {
136
+ databaseUrl = INSECURE_DATABASE_URL;
137
+ insecureDefaults.push("DATABASE_URL");
138
+ }
139
+ if (!authSecret) {
140
+ authSecret = INSECURE_PLACEHOLDER_SECRET;
141
+ insecureDefaults.push("BETTER_AUTH_SECRET");
142
+ }
143
+ if (!encryptionKey) {
144
+ encryptionKey = INSECURE_ENCRYPTION_KEY;
145
+ insecureDefaults.push("WORKSPACE_SETTINGS_ENCRYPTION_KEY");
146
+ }
147
+ }
148
+ if (insecureDefaults.length > 0) {
149
+ console.warn(
150
+ `[config:insecure-defaults] Using placeholder values for: ${insecureDefaults.join(", ")}. Do NOT use in production.`
151
+ );
152
+ }
153
+ const authUrl = env.BETTER_AUTH_URL ?? `http://localhost:${env.PORT ?? "3000"}`;
154
+ const corsOriginsRaw = env.CORS_ORIGINS ?? "";
155
+ const corsOrigins = corsOriginsRaw ? corsOriginsRaw.split(",").map((s) => s.trim()).filter(Boolean) : ["http://localhost:3000", "http://localhost:5173"];
156
+ const cspEnabled = env.CSP_ENABLED !== "false";
157
+ const cspUpgradeInsecureRequests = env.CSP_UPGRADE_INSECURE_REQUESTS !== void 0 ? env.CSP_UPGRADE_INSECURE_REQUESTS === "true" : authUrl.startsWith("https://");
158
+ const sessionCookieSecureOverride = env.SESSION_COOKIE_SECURE;
159
+ const sessionCookieSecure = sessionCookieSecureOverride !== void 0 ? sessionCookieSecureOverride === "true" : authUrl.startsWith("https://");
160
+ const githubOauth = toml.features?.github_oauth ?? env.GITHUB_OAUTH === "true";
161
+ const github = env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET ? {
162
+ clientId: env.GITHUB_CLIENT_ID,
163
+ clientSecret: env.GITHUB_CLIENT_SECRET
164
+ } : void 0;
165
+ const mailFrom = env.MAIL_FROM;
166
+ const mailTransportUrl = env.MAIL_TRANSPORT_URL;
167
+ const mail = mailFrom && mailTransportUrl ? { from: mailFrom, transportUrl: mailTransportUrl } : void 0;
168
+ const raw = {
169
+ appId,
170
+ appName,
171
+ appLogo,
172
+ port: parseInt(env.PORT ?? "3000", 10),
173
+ host: env.HOST ?? "0.0.0.0",
174
+ staticDir: env.STATIC_DIR ?? null,
175
+ databaseUrl,
176
+ stores,
177
+ cors: {
178
+ origins: corsOrigins,
179
+ credentials: true
180
+ },
181
+ bodyLimit: parseInt(env.BODY_LIMIT_BYTES ?? String(SIXTEEN_MB), 10),
182
+ logLevel: env.LOG_LEVEL ?? "info",
183
+ rateLimit: parseRateLimitOverrides(env.RATE_LIMIT_OVERRIDES_JSON),
184
+ security: {
185
+ csp: {
186
+ enabled: cspEnabled,
187
+ upgradeInsecureRequests: cspUpgradeInsecureRequests
188
+ }
189
+ },
190
+ encryption: {
191
+ workspaceSettingsKey: encryptionKey
192
+ },
193
+ auth: {
194
+ secret: authSecret,
195
+ url: authUrl,
196
+ github,
197
+ mail,
198
+ sessionTtlSeconds: parseInt(
199
+ env.SESSION_TTL_SECONDS ?? String(THIRTY_DAYS_SECONDS),
200
+ 10
201
+ ),
202
+ sessionCookieSecure
203
+ },
204
+ features: {
205
+ githubOauth: githubOauth && github !== void 0,
206
+ invitesEnabled: toml.features?.invites_enabled ?? true,
207
+ sendWelcomeEmail: env.SEND_WELCOME_EMAIL !== "false",
208
+ ...toml.features?.invite_ttl_days != null && { inviteTtlDays: toml.features.invite_ttl_days }
209
+ }
210
+ };
211
+ return validateConfig(raw);
212
+ }
213
+ function validateConfig(raw) {
214
+ const result = coreConfigSchema.safeParse(raw);
215
+ if (!result.success) {
216
+ throw new ConfigValidationError(
217
+ result.error.issues.map((i) => ({
218
+ message: i.message,
219
+ path: i.path.map((p) => typeof p === "number" ? p : String(p))
220
+ }))
221
+ );
222
+ }
223
+ return result.data;
224
+ }
225
+ function buildRuntimeConfigPayload(config) {
226
+ return {
227
+ appId: config.appId,
228
+ appName: config.appName,
229
+ appLogo: config.appLogo,
230
+ apiBase: config.auth.url,
231
+ features: {
232
+ githubOauth: config.features.githubOauth,
233
+ invitesEnabled: config.features.invitesEnabled,
234
+ sendWelcomeEmail: config.features.sendWelcomeEmail
235
+ }
236
+ };
237
+ }
238
+
239
+ // src/server/app/createCoreApp.ts
240
+ import Fastify from "fastify";
241
+ import cors from "@fastify/cors";
242
+ import helmet from "@fastify/helmet";
243
+ import { randomBytes, randomUUID } from "crypto";
244
+
245
+ // src/server/app/errorHandler.ts
246
+ function registerErrorHandler(app) {
247
+ app.setNotFoundHandler((request, reply) => {
248
+ return reply.status(404).send({
249
+ error: "Not found",
250
+ code: "not_found",
251
+ message: `Route ${request.method}:${request.url} not found`,
252
+ requestId: request.id
253
+ });
254
+ });
255
+ app.setErrorHandler((error, request, reply) => {
256
+ const requestId = request.id;
257
+ if (error instanceof HttpError) {
258
+ return reply.status(error.status).send({
259
+ error: error.message,
260
+ code: error.code,
261
+ message: error.message,
262
+ requestId
263
+ });
264
+ }
265
+ if (isValidationError(error)) {
266
+ const firstIssue = extractFirstValidationMessage(error);
267
+ return reply.status(400).send({
268
+ error: "validation_failed",
269
+ code: "validation_failed",
270
+ message: firstIssue,
271
+ requestId
272
+ });
273
+ }
274
+ if (isRateLimitError(error)) {
275
+ const retryAfter = error.retryAfter ?? 60;
276
+ reply.header("Retry-After", String(retryAfter));
277
+ return reply.status(429).send({
278
+ error: "rate_limited",
279
+ code: "rate_limited",
280
+ message: `Too many requests. Retry after ${retryAfter} seconds.`,
281
+ requestId
282
+ });
283
+ }
284
+ const fastifyErr = error;
285
+ if (fastifyErr.statusCode && fastifyErr.statusCode >= 400 && fastifyErr.statusCode < 500) {
286
+ const code = fastifyErr.code ? fastifyErr.code.toLowerCase().replace(/^fst_err_/, "") : `http_${fastifyErr.statusCode}`;
287
+ return reply.status(fastifyErr.statusCode).send({
288
+ error: fastifyErr.message,
289
+ code,
290
+ message: fastifyErr.message,
291
+ requestId
292
+ });
293
+ }
294
+ request.log.error({ err: error, requestId }, "unhandled error");
295
+ return reply.status(500).send({
296
+ error: "internal_error",
297
+ code: "internal_error",
298
+ message: "Internal server error",
299
+ requestId
300
+ });
301
+ });
302
+ }
303
+ function isValidationError(error) {
304
+ const err = error;
305
+ if (err.validation) return true;
306
+ if (err.code === "FST_ERR_VALIDATION") return true;
307
+ return false;
308
+ }
309
+ function isRateLimitError(error) {
310
+ const err = error;
311
+ return err.statusCode === 429 || err.code === "FST_ERR_RATE_LIMIT";
312
+ }
313
+ function extractFirstValidationMessage(error) {
314
+ const err = error;
315
+ if (err.validation && err.validation.length > 0) {
316
+ const first = err.validation[0];
317
+ const path = first.instancePath ?? "";
318
+ const msg = first.message ?? first.params?.issue ?? "Invalid value";
319
+ return path ? `${path}: ${msg}` : msg;
320
+ }
321
+ return error.message || "Validation failed";
322
+ }
323
+
324
+ // src/server/app/capabilities.ts
325
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
326
+ import { resolve as resolve2, dirname } from "path";
327
+ import { fileURLToPath } from "url";
328
+ var cachedVersion;
329
+ function readCorePackageVersion(startDir) {
330
+ let dir = startDir;
331
+ while (true) {
332
+ const pkgPath = resolve2(dir, "package.json");
333
+ if (existsSync2(pkgPath)) {
334
+ const pkg = JSON.parse(readFileSync2(pkgPath, "utf-8"));
335
+ if (pkg.name === "@hachej/boring-core" && typeof pkg.version === "string") {
336
+ return pkg.version;
337
+ }
338
+ }
339
+ const parent = dirname(dir);
340
+ if (parent === dir) return void 0;
341
+ dir = parent;
342
+ }
343
+ }
344
+ function getCoreVersion() {
345
+ if (cachedVersion) return cachedVersion;
346
+ try {
347
+ const dir = dirname(fileURLToPath(import.meta.url));
348
+ cachedVersion = readCorePackageVersion(dir) ?? "0.0.0";
349
+ } catch {
350
+ cachedVersion = "0.0.0";
351
+ }
352
+ return cachedVersion;
353
+ }
354
+ function registerCapabilities(app) {
355
+ const contributors = /* @__PURE__ */ new Map();
356
+ app.decorate("capabilitiesCache", null);
357
+ app.decorate(
358
+ "registerCapabilitiesContributor",
359
+ function(name, fn) {
360
+ contributors.set(name, fn);
361
+ }
362
+ );
363
+ const hasMail = !!app.config.auth.mail;
364
+ app.registerCapabilitiesContributor("core", () => {
365
+ const core = {
366
+ version: getCoreVersion(),
367
+ features: {
368
+ invitesEnabled: app.config.features.invitesEnabled,
369
+ githubOauth: app.config.features.githubOauth,
370
+ emailFlows: hasMail
371
+ },
372
+ auth: {
373
+ emailPassword: true,
374
+ github: false,
375
+ emailVerification: hasMail,
376
+ passwordReset: hasMail,
377
+ magicLink: hasMail
378
+ }
379
+ };
380
+ return { core };
381
+ });
382
+ app.addHook("onReady", async () => {
383
+ const result = {};
384
+ for (const [name, fn] of contributors) {
385
+ const partial = await fn({ config: app.config });
386
+ for (const [key, value] of Object.entries(partial)) {
387
+ if (value !== void 0) {
388
+ result[key] = value;
389
+ }
390
+ }
391
+ }
392
+ app.capabilitiesCache = result;
393
+ });
394
+ app.get("/api/v1/capabilities", async () => {
395
+ return app.capabilitiesCache;
396
+ });
397
+ }
398
+
399
+ // src/server/security/rateLimit.ts
400
+ import rateLimit from "@fastify/rate-limit";
401
+ var DEFAULT_RATE_LIMIT_RULES = [
402
+ {
403
+ endpoint: "/auth/sign-in/email",
404
+ url: "/auth/sign-in/email",
405
+ method: "POST",
406
+ max: 5,
407
+ timeWindow: "1 minute"
408
+ },
409
+ {
410
+ endpoint: "/auth/sign-up/email",
411
+ url: "/auth/sign-up/email",
412
+ method: "POST",
413
+ max: 3,
414
+ timeWindow: "1 hour"
415
+ },
416
+ {
417
+ endpoint: "/auth/forget-password",
418
+ url: "/auth/forget-password",
419
+ method: "POST",
420
+ max: 3,
421
+ timeWindow: "1 hour"
422
+ },
423
+ {
424
+ endpoint: "/auth/send-verification-email",
425
+ url: "/auth/send-verification-email",
426
+ method: "POST",
427
+ max: 3,
428
+ timeWindow: "1 hour"
429
+ },
430
+ {
431
+ // Override key uses the templated route string, not a concrete workspace ID.
432
+ endpoint: "/api/v1/workspaces/:id/invites",
433
+ url: "/api/v1/workspaces/:id/invites",
434
+ method: "POST",
435
+ max: 20,
436
+ timeWindow: "1 hour",
437
+ keyGenerator: (req) => {
438
+ const workspaceId = req.params.id ?? "unknown";
439
+ return workspaceId;
440
+ }
441
+ },
442
+ {
443
+ endpoint: "/api/v1/invites/resolve",
444
+ url: "/api/v1/invites/resolve",
445
+ method: "POST",
446
+ max: 60,
447
+ timeWindow: "1 minute"
448
+ },
449
+ {
450
+ endpoint: "/api/v1/invites/accept",
451
+ url: "/api/v1/invites/accept",
452
+ method: "POST",
453
+ max: 10,
454
+ timeWindow: "1 minute",
455
+ keyGenerator: (req) => {
456
+ const userId = req.user?.id ?? "anon";
457
+ return `${req.ip}:${userId}`;
458
+ }
459
+ }
460
+ ];
461
+ function matchesRule(routeOptions, rule) {
462
+ if (routeOptions.url !== rule.url) return false;
463
+ const methods = Array.isArray(routeOptions.method) ? routeOptions.method : [routeOptions.method];
464
+ return methods.some((m) => m.toUpperCase() === rule.method);
465
+ }
466
+ function resolveRule(rule, overrides) {
467
+ if (!overrides) return rule;
468
+ const byMethodAndPath = overrides[`${rule.method} ${rule.endpoint}`];
469
+ const byPath = overrides[rule.endpoint];
470
+ const override = byMethodAndPath ?? byPath;
471
+ if (!override) return rule;
472
+ return {
473
+ ...rule,
474
+ max: override.max,
475
+ timeWindow: override.window
476
+ };
477
+ }
478
+ async function registerRateLimits(app) {
479
+ const overrides = app.config.rateLimit;
480
+ app.addHook("onRoute", (routeOptions) => {
481
+ const baseRule = DEFAULT_RATE_LIMIT_RULES.find(
482
+ (r) => matchesRule(routeOptions, r)
483
+ );
484
+ if (!baseRule) return;
485
+ const rule = resolveRule(baseRule, overrides);
486
+ routeOptions.config = {
487
+ ...routeOptions.config,
488
+ rateLimit: {
489
+ max: rule.max,
490
+ timeWindow: rule.timeWindow,
491
+ ...rule.keyGenerator ? { keyGenerator: rule.keyGenerator } : {}
492
+ }
493
+ };
494
+ });
495
+ await app.register(rateLimit, { global: false });
496
+ }
497
+
498
+ // src/server/app/createCoreApp.ts
499
+ var DEFAULT_REDACTION_KEYWORDS = [
500
+ "secret",
501
+ "token",
502
+ "clientsecret",
503
+ "password",
504
+ "authorization",
505
+ "cookie"
506
+ ];
507
+ var SHUTDOWN_GRACE_MS = 3e4;
508
+ var CSP_NONCE_SIZE_BYTES = 16;
509
+ function redactObject(obj, keywords) {
510
+ const result = {};
511
+ for (const [key, value] of Object.entries(obj)) {
512
+ const keyLower = key.toLowerCase();
513
+ if (keywords.some((kw) => keyLower.includes(kw))) {
514
+ result[key] = "[REDACTED]";
515
+ } else if (value && typeof value === "object" && !Array.isArray(value)) {
516
+ result[key] = redactObject(
517
+ value,
518
+ keywords
519
+ );
520
+ } else {
521
+ result[key] = value;
522
+ }
523
+ }
524
+ return result;
525
+ }
526
+ async function closeDbPoolIfPresent(app) {
527
+ const maybeDb = app.db;
528
+ if (!maybeDb || typeof maybeDb !== "object") return;
529
+ const db = maybeDb;
530
+ const closeFn = typeof db.end === "function" ? db.end.bind(db) : typeof db.close === "function" ? db.close.bind(db) : null;
531
+ if (!closeFn) return;
532
+ await closeFn();
533
+ }
534
+ function installShutdownHandlers(app) {
535
+ let shuttingDown = false;
536
+ const onSignal = async (signal) => {
537
+ if (shuttingDown) return;
538
+ shuttingDown = true;
539
+ app.log.info({ signal }, "shutdown:start");
540
+ try {
541
+ let timeoutHandle;
542
+ const result = await Promise.race([
543
+ app.close(),
544
+ new Promise((resolve3) => {
545
+ timeoutHandle = setTimeout(() => {
546
+ resolve3("timeout");
547
+ }, SHUTDOWN_GRACE_MS);
548
+ })
549
+ ]);
550
+ if (timeoutHandle) clearTimeout(timeoutHandle);
551
+ if (result === "timeout") {
552
+ app.log.warn(
553
+ { signal, timeoutMs: SHUTDOWN_GRACE_MS },
554
+ "shutdown:grace-exceeded"
555
+ );
556
+ try {
557
+ await closeDbPoolIfPresent(app);
558
+ } catch (error) {
559
+ app.log.error(
560
+ { err: error, signal },
561
+ "shutdown:db-close-failed"
562
+ );
563
+ }
564
+ process.exit(1);
565
+ return;
566
+ }
567
+ try {
568
+ await closeDbPoolIfPresent(app);
569
+ } catch (error) {
570
+ app.log.error({ err: error, signal }, "shutdown:db-close-failed");
571
+ process.exit(1);
572
+ return;
573
+ }
574
+ app.log.info({ signal }, "shutdown:complete");
575
+ process.exit(0);
576
+ } catch (error) {
577
+ app.log.error({ err: error, signal }, "shutdown:error");
578
+ try {
579
+ await closeDbPoolIfPresent(app);
580
+ } catch (dbError) {
581
+ app.log.error({ err: dbError, signal }, "shutdown:db-close-failed");
582
+ }
583
+ process.exit(1);
584
+ }
585
+ };
586
+ const sigtermHandler = () => {
587
+ void onSignal("SIGTERM");
588
+ };
589
+ const sigintHandler = () => {
590
+ void onSignal("SIGINT");
591
+ };
592
+ process.once("SIGTERM", sigtermHandler);
593
+ process.once("SIGINT", sigintHandler);
594
+ app.addHook("onClose", async () => {
595
+ process.removeListener("SIGTERM", sigtermHandler);
596
+ process.removeListener("SIGINT", sigintHandler);
597
+ });
598
+ }
599
+ async function createCoreApp(config, options) {
600
+ const redactionKeywords = [...DEFAULT_REDACTION_KEYWORDS];
601
+ const app = Fastify({
602
+ trustProxy: true,
603
+ bodyLimit: config.bodyLimit,
604
+ genReqId: (req) => {
605
+ const incoming = req.headers["x-request-id"];
606
+ if (typeof incoming === "string" && incoming.length > 0 && incoming.length <= 128) {
607
+ return incoming;
608
+ }
609
+ return randomUUID();
610
+ },
611
+ logger: {
612
+ level: config.logLevel,
613
+ serializers: {
614
+ req(req) {
615
+ return redactObject(
616
+ {
617
+ method: req.method,
618
+ url: req.url,
619
+ hostname: req.hostname,
620
+ remoteAddress: req.ip,
621
+ headers: req.headers
622
+ },
623
+ redactionKeywords
624
+ );
625
+ },
626
+ res(res) {
627
+ return { statusCode: res.statusCode };
628
+ }
629
+ }
630
+ }
631
+ });
632
+ app.decorate("config", config);
633
+ app.decorate("provisioner", options?.provisioner ?? null);
634
+ app.decorate("addRedactionPaths", function(paths) {
635
+ for (const p of paths) {
636
+ redactionKeywords.push(p.toLowerCase());
637
+ }
638
+ });
639
+ app.addHook("onSend", async (request, reply) => {
640
+ reply.header("x-request-id", request.id);
641
+ });
642
+ app.addHook("onRequest", async (request) => {
643
+ const nonce = randomBytes(CSP_NONCE_SIZE_BYTES).toString("base64");
644
+ request.cspNonce = nonce;
645
+ request.raw.cspNonce = nonce;
646
+ });
647
+ await app.register(cors, {
648
+ origin: config.cors.origins.length > 0 ? config.cors.origins : true,
649
+ credentials: config.cors.credentials
650
+ });
651
+ const cspEnabled = config.security?.csp?.enabled ?? true;
652
+ const cspUpgradeInsecureRequests = config.security?.csp?.upgradeInsecureRequests ?? config.auth.url.startsWith("https://");
653
+ await app.register(helmet, {
654
+ hsts: {
655
+ maxAge: 31536e3,
656
+ includeSubDomains: true,
657
+ preload: true
658
+ },
659
+ frameguard: {
660
+ action: "deny"
661
+ },
662
+ noSniff: true,
663
+ referrerPolicy: {
664
+ policy: "strict-origin-when-cross-origin"
665
+ },
666
+ contentSecurityPolicy: cspEnabled ? {
667
+ directives: {
668
+ defaultSrc: ["'self'"],
669
+ scriptSrc: [
670
+ "'self'",
671
+ (request) => `'nonce-${request.cspNonce ?? ""}'`
672
+ ],
673
+ styleSrc: [
674
+ "'self'",
675
+ (request) => `'nonce-${request.cspNonce ?? ""}'`
676
+ ],
677
+ imgSrc: ["'self'", "data:", "blob:"],
678
+ connectSrc: ["'self'"],
679
+ fontSrc: ["'self'"],
680
+ objectSrc: ["'none'"],
681
+ frameAncestors: ["'none'"],
682
+ baseUri: ["'self'"],
683
+ formAction: ["'self'"],
684
+ upgradeInsecureRequests: cspUpgradeInsecureRequests ? [] : null
685
+ }
686
+ } : false
687
+ });
688
+ registerErrorHandler(app);
689
+ registerCapabilities(app);
690
+ await registerRateLimits(app);
691
+ const manageShutdown = options?.manageShutdown ?? true;
692
+ if (manageShutdown) {
693
+ installShutdownHandlers(app);
694
+ }
695
+ return app;
696
+ }
697
+
698
+ // src/server/app/routes.ts
699
+ import fp from "fastify-plugin";
700
+ import { z as z2 } from "zod";
701
+
702
+ // src/server/auth/deleteUserCompletely.ts
703
+ import { and, eq, isNotNull, isNull, sql } from "drizzle-orm";
704
+ var RETRYABLE_TX_ERROR_CODES = /* @__PURE__ */ new Set(["40001", "40P01"]);
705
+ var SERIALIZATION_RETRY_LIMIT = 5;
706
+ var BASE_RETRY_DELAY_MS = 25;
707
+ function isRetryableTxFailure(error) {
708
+ return typeof error === "object" && error !== null && "code" in error && RETRYABLE_TX_ERROR_CODES.has(String(error.code));
709
+ }
710
+ function sleep(ms) {
711
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
712
+ }
713
+ async function deleteUserCompletely(userId, deps) {
714
+ for (let attempt = 1; attempt <= SERIALIZATION_RETRY_LIMIT; attempt += 1) {
715
+ try {
716
+ await deps.db.transaction(async (tx) => {
717
+ await tx.execute(sql`SET TRANSACTION ISOLATION LEVEL SERIALIZABLE`);
718
+ const ownerWorkspaces = await tx.select({ id: workspaces.id }).from(workspaces).innerJoin(
719
+ workspaceMembers,
720
+ and(
721
+ eq(workspaceMembers.workspaceId, workspaces.id),
722
+ eq(workspaceMembers.userId, userId),
723
+ eq(workspaceMembers.role, "owner")
724
+ )
725
+ ).where(isNull(workspaces.deletedAt));
726
+ for (const workspace of ownerWorkspaces) {
727
+ await tx.execute(sql`
728
+ SELECT user_id
729
+ FROM workspace_members
730
+ WHERE workspace_id = ${workspace.id}
731
+ AND role = 'owner'
732
+ FOR UPDATE
733
+ `);
734
+ const [ownerCount] = await tx.select({ count: sql`count(*)::int` }).from(workspaceMembers).where(
735
+ and(
736
+ eq(workspaceMembers.workspaceId, workspace.id),
737
+ eq(workspaceMembers.role, sql`'owner'`)
738
+ )
739
+ );
740
+ if (Number(ownerCount.count) !== 1) {
741
+ continue;
742
+ }
743
+ const [oldestEditor] = await tx.select({ userId: workspaceMembers.userId }).from(workspaceMembers).where(
744
+ and(
745
+ eq(workspaceMembers.workspaceId, workspace.id),
746
+ eq(workspaceMembers.role, "editor"),
747
+ sql`${workspaceMembers.userId} <> ${userId}`
748
+ )
749
+ ).orderBy(workspaceMembers.createdAt, workspaceMembers.userId).limit(1);
750
+ if (oldestEditor) {
751
+ await tx.update(workspaceMembers).set({ role: "owner" }).where(
752
+ and(
753
+ eq(workspaceMembers.workspaceId, workspace.id),
754
+ eq(workspaceMembers.userId, oldestEditor.userId)
755
+ )
756
+ );
757
+ await tx.update(workspaces).set({ createdBy: oldestEditor.userId }).where(eq(workspaces.id, workspace.id));
758
+ continue;
759
+ }
760
+ await tx.delete(workspaceInvites).where(eq(workspaceInvites.workspaceId, workspace.id));
761
+ await tx.delete(workspaceSettings).where(eq(workspaceSettings.workspaceId, workspace.id));
762
+ await tx.delete(workspaceRuntimes).where(eq(workspaceRuntimes.workspaceId, workspace.id));
763
+ await tx.delete(workspaceMembers).where(eq(workspaceMembers.workspaceId, workspace.id));
764
+ await tx.delete(workspaces).where(eq(workspaces.id, workspace.id));
765
+ }
766
+ await tx.execute(sql`
767
+ UPDATE workspaces
768
+ SET created_by = (
769
+ SELECT wm.user_id
770
+ FROM workspace_members AS wm
771
+ WHERE wm.workspace_id = workspaces.id
772
+ AND wm.user_id <> ${userId}
773
+ ORDER BY
774
+ CASE wm.role
775
+ WHEN 'owner' THEN 0
776
+ WHEN 'editor' THEN 1
777
+ ELSE 2
778
+ END,
779
+ wm.created_at ASC,
780
+ wm.user_id ASC
781
+ LIMIT 1
782
+ )
783
+ WHERE workspaces.created_by = ${userId}
784
+ AND EXISTS (
785
+ SELECT 1
786
+ FROM workspace_members AS wm
787
+ WHERE wm.workspace_id = workspaces.id
788
+ AND wm.user_id <> ${userId}
789
+ )
790
+ `);
791
+ await tx.delete(workspaceMembers).where(eq(workspaceMembers.userId, userId));
792
+ await tx.delete(workspaceInvites).where(
793
+ and(
794
+ eq(workspaceInvites.createdBy, userId),
795
+ isNull(workspaceInvites.acceptedAt)
796
+ )
797
+ );
798
+ await tx.update(workspaceInvites).set({ createdBy: null }).where(
799
+ and(
800
+ eq(workspaceInvites.createdBy, userId),
801
+ isNotNull(workspaceInvites.acceptedAt)
802
+ )
803
+ );
804
+ const remainingCreatedWorkspaces = await tx.select({ id: workspaces.id }).from(workspaces).where(eq(workspaces.createdBy, userId));
805
+ for (const workspace of remainingCreatedWorkspaces) {
806
+ await tx.delete(workspaceInvites).where(eq(workspaceInvites.workspaceId, workspace.id));
807
+ await tx.delete(workspaceSettings).where(eq(workspaceSettings.workspaceId, workspace.id));
808
+ await tx.delete(workspaceRuntimes).where(eq(workspaceRuntimes.workspaceId, workspace.id));
809
+ await tx.delete(workspaceMembers).where(eq(workspaceMembers.workspaceId, workspace.id));
810
+ await tx.delete(workspaces).where(eq(workspaces.id, workspace.id));
811
+ }
812
+ const [userRow] = await tx.select({ email: users.email }).from(users).where(eq(users.id, userId)).limit(1);
813
+ if (userRow?.email) {
814
+ await tx.delete(verification_tokens).where(eq(verification_tokens.identifier, userRow.email));
815
+ }
816
+ await tx.delete(users).where(eq(users.id, userId));
817
+ });
818
+ return;
819
+ } catch (error) {
820
+ if (attempt < SERIALIZATION_RETRY_LIMIT && isRetryableTxFailure(error)) {
821
+ await sleep(BASE_RETRY_DELAY_MS * attempt);
822
+ continue;
823
+ }
824
+ throw error;
825
+ }
826
+ }
827
+ }
828
+
829
+ // src/server/app/routes.ts
830
+ var HEALTH_DB_TIMEOUT_MS = 2e3;
831
+ async function pingDatabase(sqlClient, timeoutMs) {
832
+ const timeoutMessage = `Database ping timed out after ${timeoutMs}ms`;
833
+ let timeoutId;
834
+ try {
835
+ await Promise.race([
836
+ sqlClient`SELECT 1`,
837
+ new Promise((_, reject) => {
838
+ timeoutId = setTimeout(() => {
839
+ reject(new Error(timeoutMessage));
840
+ }, timeoutMs);
841
+ })
842
+ ]);
843
+ return { ok: true };
844
+ } catch (error) {
845
+ if (error instanceof Error && error.message.length > 0) {
846
+ return { ok: false, message: error.message };
847
+ }
848
+ return { ok: false, message: "Database ping failed" };
849
+ } finally {
850
+ if (timeoutId) clearTimeout(timeoutId);
851
+ }
852
+ }
853
+ var updateSettingsBody = z2.object({
854
+ displayName: z2.string().optional(),
855
+ settings: z2.record(z2.unknown()).optional()
856
+ }).strict();
857
+ var deleteMeBody = z2.object({
858
+ confirm: z2.string().optional()
859
+ }).optional();
860
+ function toHeaders(source) {
861
+ const headers = new Headers();
862
+ for (const [key, value] of Object.entries(source)) {
863
+ if (!value) continue;
864
+ headers.set(key, Array.isArray(value) ? value[0] : value);
865
+ }
866
+ return headers;
867
+ }
868
+ var routesPlugin = async (app, opts) => {
869
+ const { sql: sql3, db, userStore } = opts;
870
+ app.get("/health", async (request, reply) => {
871
+ if (sql3) {
872
+ const result = await pingDatabase(sql3, HEALTH_DB_TIMEOUT_MS);
873
+ if (!result.ok) {
874
+ reply.status(503);
875
+ return {
876
+ error: "db_unavailable",
877
+ code: ERROR_CODES.DB_UNAVAILABLE,
878
+ message: result.message,
879
+ requestId: request.id
880
+ };
881
+ }
882
+ }
883
+ return { ok: true };
884
+ });
885
+ app.get("/api/v1/config", async () => {
886
+ return buildRuntimeConfigPayload(app.config);
887
+ });
888
+ app.get("/api/v1/me", async (request) => {
889
+ const user = request.user;
890
+ const settings = await userStore.getUserSettings(user.id, app.config.appId);
891
+ return { user, settings };
892
+ });
893
+ app.put("/api/v1/me/settings", async (request, reply) => {
894
+ const parsed = updateSettingsBody.safeParse(request.body);
895
+ if (!parsed.success) {
896
+ throw new HttpError({
897
+ status: 400,
898
+ code: ERROR_CODES.VALIDATION_FAILED,
899
+ message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
900
+ requestId: request.id
901
+ });
902
+ }
903
+ const result = await userStore.putUserSettings(
904
+ request.user.id,
905
+ app.config.appId,
906
+ parsed.data
907
+ );
908
+ reply.status(200);
909
+ return result;
910
+ });
911
+ app.delete("/api/v1/me", async (request, reply) => {
912
+ const parsed = deleteMeBody.safeParse(request.body);
913
+ if (!parsed.success) {
914
+ throw new HttpError({
915
+ status: 400,
916
+ code: ERROR_CODES.VALIDATION_FAILED,
917
+ message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
918
+ requestId: request.id
919
+ });
920
+ }
921
+ const user = request.user;
922
+ if (!user.email || parsed.data?.confirm !== user.email) {
923
+ throw new HttpError({
924
+ status: 400,
925
+ code: ERROR_CODES.VALIDATION_FAILED,
926
+ message: "confirm must match the signed-in email",
927
+ requestId: request.id
928
+ });
929
+ }
930
+ if (!db) {
931
+ throw new HttpError({
932
+ status: 500,
933
+ code: ERROR_CODES.INTERNAL_ERROR,
934
+ message: "DELETE /api/v1/me is not configured",
935
+ requestId: request.id
936
+ });
937
+ }
938
+ const startedAt = Date.now();
939
+ request.log.info(
940
+ { event: "user.delete.start", userId: user.id },
941
+ "user.delete.start"
942
+ );
943
+ await deleteUserCompletely(user.id, { db });
944
+ const signOutResponse = await app.auth.api.signOut({
945
+ headers: toHeaders(request.headers),
946
+ asResponse: true
947
+ });
948
+ const responseHeaders = signOutResponse.headers;
949
+ const setCookies = typeof responseHeaders.getSetCookie === "function" ? responseHeaders.getSetCookie() : responseHeaders.get("set-cookie") ? [responseHeaders.get("set-cookie")] : [];
950
+ if (setCookies.length > 0) {
951
+ reply.header("set-cookie", setCookies.length === 1 ? setCookies[0] : setCookies);
952
+ }
953
+ request.log.info(
954
+ {
955
+ event: "user.delete.complete",
956
+ userId: user.id,
957
+ durationMs: Date.now() - startedAt
958
+ },
959
+ "user.delete.complete"
960
+ );
961
+ reply.status(200);
962
+ return { deleted: true };
963
+ });
964
+ };
965
+ var registerRoutes = fp(routesPlugin, { name: "core-routes" });
966
+
967
+ // src/server/mail/transport.ts
968
+ var MailDeliveryError = class extends Error {
969
+ statusCode;
970
+ constructor(message, statusCode) {
971
+ super(message);
972
+ this.name = "MailDeliveryError";
973
+ this.statusCode = statusCode;
974
+ }
975
+ };
976
+ function createMailTransport(url, from, env = "development") {
977
+ if (url.startsWith("resend://")) {
978
+ const apiKey = url.slice("resend://".length);
979
+ return new ResendTransport(apiKey, from);
980
+ }
981
+ if (url.startsWith("smtp://") || url.startsWith("smtps://")) {
982
+ return new SmtpTransport(url, from);
983
+ }
984
+ if (url.startsWith("console-capture://")) {
985
+ if (env !== "test") {
986
+ throw new ConfigValidationError([
987
+ {
988
+ message: "console-capture:// transport is only allowed in test env",
989
+ path: ["auth", "mail", "transportUrl"]
990
+ }
991
+ ]);
992
+ }
993
+ const outputPath = url.slice("console-capture://".length) || void 0;
994
+ return new ConsoleCaptureTransport(outputPath);
995
+ }
996
+ if (url.startsWith("console://")) {
997
+ if (env === "production") {
998
+ throw new ConfigValidationError([
999
+ {
1000
+ message: "console mail transport is not allowed in production",
1001
+ path: ["auth", "mail", "transportUrl"]
1002
+ }
1003
+ ]);
1004
+ }
1005
+ return new ConsoleTransport();
1006
+ }
1007
+ throw new ConfigValidationError([
1008
+ {
1009
+ message: `Unknown mail transport scheme: ${url.split("://")[0]}://. Expected resend://, smtp://, smtps://, console://, or console-capture://`,
1010
+ path: ["auth", "mail", "transportUrl"]
1011
+ }
1012
+ ]);
1013
+ }
1014
+ async function fetchWithRetry(url, init, maxRetries = 3) {
1015
+ const delays = [500, 1e3, 2e3];
1016
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
1017
+ const delay = delays[Math.min(attempt, delays.length - 1)];
1018
+ let response;
1019
+ try {
1020
+ response = await fetch(url, init);
1021
+ } catch (err) {
1022
+ if (attempt < maxRetries) {
1023
+ await new Promise((r) => setTimeout(r, delay));
1024
+ continue;
1025
+ }
1026
+ throw new MailDeliveryError(
1027
+ `Network error after ${maxRetries + 1} attempts: ${err instanceof Error ? err.message : String(err)}`
1028
+ );
1029
+ }
1030
+ if (response.ok) return response;
1031
+ if (response.status >= 400 && response.status < 500) {
1032
+ const body3 = await response.text();
1033
+ throw new MailDeliveryError(
1034
+ `Resend API error ${response.status}: ${body3}`,
1035
+ response.status
1036
+ );
1037
+ }
1038
+ if (attempt < maxRetries) {
1039
+ await new Promise((r) => setTimeout(r, delay));
1040
+ continue;
1041
+ }
1042
+ const body2 = await response.text();
1043
+ throw new MailDeliveryError(
1044
+ `Resend API error ${response.status} after ${maxRetries + 1} attempts: ${body2}`,
1045
+ response.status
1046
+ );
1047
+ }
1048
+ throw new MailDeliveryError("Unreachable");
1049
+ }
1050
+ var ResendTransport = class {
1051
+ constructor(apiKey, from) {
1052
+ this.apiKey = apiKey;
1053
+ this.from = from;
1054
+ }
1055
+ apiKey;
1056
+ from;
1057
+ async send(email) {
1058
+ const response = await fetchWithRetry(
1059
+ "https://api.resend.com/emails",
1060
+ {
1061
+ method: "POST",
1062
+ headers: {
1063
+ "Content-Type": "application/json",
1064
+ Authorization: `Bearer ${this.apiKey}`
1065
+ },
1066
+ body: JSON.stringify({
1067
+ from: this.from,
1068
+ to: email.to,
1069
+ subject: email.subject,
1070
+ html: email.html,
1071
+ text: email.text
1072
+ })
1073
+ }
1074
+ );
1075
+ const data = await response.json();
1076
+ return { id: data.id };
1077
+ }
1078
+ };
1079
+ var SmtpTransport = class {
1080
+ connectionUrl;
1081
+ from;
1082
+ constructor(url, from) {
1083
+ this.connectionUrl = url;
1084
+ this.from = from;
1085
+ }
1086
+ async send(email) {
1087
+ try {
1088
+ const { createTransport } = await import("nodemailer");
1089
+ const transporter = createTransport(this.connectionUrl);
1090
+ const info = await transporter.sendMail({
1091
+ from: this.from,
1092
+ to: email.to,
1093
+ subject: email.subject,
1094
+ html: email.html,
1095
+ text: email.text
1096
+ });
1097
+ return { id: info.messageId };
1098
+ } catch (err) {
1099
+ if (err instanceof MailDeliveryError) throw err;
1100
+ throw new MailDeliveryError(
1101
+ `SMTP send failed: ${err instanceof Error ? err.message : String(err)}`
1102
+ );
1103
+ }
1104
+ }
1105
+ };
1106
+ var consoleCounter = 0;
1107
+ var ConsoleTransport = class {
1108
+ async send(email) {
1109
+ const id = `console-${++consoleCounter}-${Date.now()}`;
1110
+ const logEntry = {
1111
+ type: "mail_sent",
1112
+ id,
1113
+ to: email.to,
1114
+ subject: email.subject,
1115
+ html: email.html,
1116
+ text: email.text,
1117
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1118
+ };
1119
+ console.info("[mail:console]", JSON.stringify(logEntry));
1120
+ return { id };
1121
+ }
1122
+ };
1123
+ var ConsoleCaptureTransport = class {
1124
+ constructor(outputPath) {
1125
+ this.outputPath = outputPath;
1126
+ }
1127
+ outputPath;
1128
+ async send(email) {
1129
+ const id = `capture-${++consoleCounter}-${Date.now()}`;
1130
+ const logEntry = {
1131
+ type: "mail_sent",
1132
+ id,
1133
+ to: email.to,
1134
+ subject: email.subject,
1135
+ html: email.html,
1136
+ text: email.text,
1137
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1138
+ };
1139
+ console.info("[mail:console-capture]", JSON.stringify(logEntry));
1140
+ const { appendFileSync } = await import("fs");
1141
+ const path = this.outputPath ?? `/tmp/test-mail-${process.pid}.log`;
1142
+ appendFileSync(path, JSON.stringify(logEntry) + "\n");
1143
+ return { id };
1144
+ }
1145
+ };
1146
+
1147
+ // src/server/mail/templates/index.ts
1148
+ import { render } from "@react-email/render";
1149
+
1150
+ // src/server/mail/templates/VerifyEmail.tsx
1151
+ import { Button, Section as Section2, Text as Text2 } from "@react-email/components";
1152
+
1153
+ // src/server/mail/templates/Layout.tsx
1154
+ import {
1155
+ Body,
1156
+ Container,
1157
+ Head,
1158
+ Html,
1159
+ Preview,
1160
+ Section,
1161
+ Text,
1162
+ Hr
1163
+ } from "@react-email/components";
1164
+ import { jsx, jsxs } from "react/jsx-runtime";
1165
+ function Layout({ preview, appName, children }) {
1166
+ return /* @__PURE__ */ jsxs(Html, { children: [
1167
+ /* @__PURE__ */ jsx(Head, {}),
1168
+ /* @__PURE__ */ jsx(Preview, { children: preview }),
1169
+ /* @__PURE__ */ jsx(Body, { style: body, children: /* @__PURE__ */ jsxs(Container, { style: container, children: [
1170
+ /* @__PURE__ */ jsx(Section, { style: header, children: /* @__PURE__ */ jsx(Text, { style: logo, children: appName }) }),
1171
+ children,
1172
+ /* @__PURE__ */ jsx(Hr, { style: hr }),
1173
+ /* @__PURE__ */ jsxs(Text, { style: footer, children: [
1174
+ "\xA9 ",
1175
+ (/* @__PURE__ */ new Date()).getFullYear(),
1176
+ " ",
1177
+ appName,
1178
+ ". All rights reserved."
1179
+ ] })
1180
+ ] }) })
1181
+ ] });
1182
+ }
1183
+ var body = {
1184
+ backgroundColor: "#f6f9fc",
1185
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Ubuntu, sans-serif'
1186
+ };
1187
+ var container = {
1188
+ backgroundColor: "#ffffff",
1189
+ margin: "0 auto",
1190
+ padding: "20px 0 48px",
1191
+ marginBottom: "64px",
1192
+ maxWidth: "560px"
1193
+ };
1194
+ var header = {
1195
+ padding: "0 48px"
1196
+ };
1197
+ var logo = {
1198
+ fontSize: "20px",
1199
+ fontWeight: "bold",
1200
+ color: "#1a1a1a",
1201
+ margin: "20px 0"
1202
+ };
1203
+ var hr = {
1204
+ borderColor: "#e6ebf1",
1205
+ margin: "20px 48px"
1206
+ };
1207
+ var footer = {
1208
+ color: "#8898aa",
1209
+ fontSize: "12px",
1210
+ lineHeight: "16px",
1211
+ padding: "0 48px"
1212
+ };
1213
+
1214
+ // src/server/mail/templates/VerifyEmail.tsx
1215
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1216
+ function VerifyEmail({
1217
+ verifyUrl,
1218
+ appName,
1219
+ expiresInHours
1220
+ }) {
1221
+ return /* @__PURE__ */ jsx2(Layout, { preview: `Verify your ${appName} email address`, appName, children: /* @__PURE__ */ jsxs2(Section2, { style: content, children: [
1222
+ /* @__PURE__ */ jsx2(Text2, { style: heading, children: "Verify your email address" }),
1223
+ /* @__PURE__ */ jsxs2(Text2, { style: paragraph, children: [
1224
+ "Click the button below to verify your email address and activate your",
1225
+ " ",
1226
+ appName,
1227
+ " account."
1228
+ ] }),
1229
+ /* @__PURE__ */ jsx2(Button, { style: button, href: verifyUrl, children: "Verify email" }),
1230
+ /* @__PURE__ */ jsxs2(Text2, { style: hint, children: [
1231
+ "This link expires in ",
1232
+ expiresInHours,
1233
+ " ",
1234
+ expiresInHours === 1 ? "hour" : "hours",
1235
+ ". If you didn't create an account, you can safely ignore this email."
1236
+ ] })
1237
+ ] }) });
1238
+ }
1239
+ var content = { padding: "0 48px" };
1240
+ var heading = {
1241
+ fontSize: "24px",
1242
+ fontWeight: "bold",
1243
+ color: "#1a1a1a",
1244
+ margin: "0 0 16px"
1245
+ };
1246
+ var paragraph = {
1247
+ fontSize: "14px",
1248
+ lineHeight: "24px",
1249
+ color: "#525f7f",
1250
+ margin: "0 0 24px"
1251
+ };
1252
+ var button = {
1253
+ backgroundColor: "#1a1a1a",
1254
+ borderRadius: "6px",
1255
+ color: "#ffffff",
1256
+ fontSize: "14px",
1257
+ fontWeight: "600",
1258
+ textDecoration: "none",
1259
+ textAlign: "center",
1260
+ display: "block",
1261
+ padding: "12px 24px"
1262
+ };
1263
+ var hint = {
1264
+ fontSize: "12px",
1265
+ lineHeight: "20px",
1266
+ color: "#8898aa",
1267
+ margin: "24px 0 0"
1268
+ };
1269
+
1270
+ // src/server/mail/templates/ResetPassword.tsx
1271
+ import { Button as Button2, Section as Section3, Text as Text3 } from "@react-email/components";
1272
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
1273
+ function ResetPassword({
1274
+ resetUrl,
1275
+ appName,
1276
+ expiresInHours
1277
+ }) {
1278
+ return /* @__PURE__ */ jsx3(
1279
+ Layout,
1280
+ {
1281
+ preview: `Reset your ${appName} password`,
1282
+ appName,
1283
+ children: /* @__PURE__ */ jsxs3(Section3, { style: content2, children: [
1284
+ /* @__PURE__ */ jsx3(Text3, { style: heading2, children: "Reset your password" }),
1285
+ /* @__PURE__ */ jsxs3(Text3, { style: paragraph2, children: [
1286
+ "We received a request to reset your ",
1287
+ appName,
1288
+ " password. Click the button below to choose a new one."
1289
+ ] }),
1290
+ /* @__PURE__ */ jsx3(Button2, { style: button2, href: resetUrl, children: "Reset password" }),
1291
+ /* @__PURE__ */ jsxs3(Text3, { style: hint2, children: [
1292
+ "This link expires in ",
1293
+ expiresInHours,
1294
+ " ",
1295
+ expiresInHours === 1 ? "hour" : "hours",
1296
+ ". If you didn't request a password reset, you can safely ignore this email."
1297
+ ] })
1298
+ ] })
1299
+ }
1300
+ );
1301
+ }
1302
+ var content2 = { padding: "0 48px" };
1303
+ var heading2 = {
1304
+ fontSize: "24px",
1305
+ fontWeight: "bold",
1306
+ color: "#1a1a1a",
1307
+ margin: "0 0 16px"
1308
+ };
1309
+ var paragraph2 = {
1310
+ fontSize: "14px",
1311
+ lineHeight: "24px",
1312
+ color: "#525f7f",
1313
+ margin: "0 0 24px"
1314
+ };
1315
+ var button2 = {
1316
+ backgroundColor: "#1a1a1a",
1317
+ borderRadius: "6px",
1318
+ color: "#ffffff",
1319
+ fontSize: "14px",
1320
+ fontWeight: "600",
1321
+ textDecoration: "none",
1322
+ textAlign: "center",
1323
+ display: "block",
1324
+ padding: "12px 24px"
1325
+ };
1326
+ var hint2 = {
1327
+ fontSize: "12px",
1328
+ lineHeight: "20px",
1329
+ color: "#8898aa",
1330
+ margin: "24px 0 0"
1331
+ };
1332
+
1333
+ // src/server/mail/templates/MagicLink.tsx
1334
+ import { Button as Button3, Section as Section4, Text as Text4 } from "@react-email/components";
1335
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1336
+ function MagicLink({
1337
+ loginUrl,
1338
+ appName,
1339
+ expiresInMinutes
1340
+ }) {
1341
+ return /* @__PURE__ */ jsx4(Layout, { preview: `Sign in to ${appName}`, appName, children: /* @__PURE__ */ jsxs4(Section4, { style: content3, children: [
1342
+ /* @__PURE__ */ jsxs4(Text4, { style: heading3, children: [
1343
+ "Sign in to ",
1344
+ appName
1345
+ ] }),
1346
+ /* @__PURE__ */ jsx4(Text4, { style: paragraph3, children: "Click the button below to sign in. No password needed." }),
1347
+ /* @__PURE__ */ jsx4(Button3, { style: button3, href: loginUrl, children: "Sign in" }),
1348
+ /* @__PURE__ */ jsxs4(Text4, { style: hint3, children: [
1349
+ "This link expires in ",
1350
+ expiresInMinutes,
1351
+ " ",
1352
+ expiresInMinutes === 1 ? "minute" : "minutes",
1353
+ ". If you didn't request this, you can safely ignore this email."
1354
+ ] })
1355
+ ] }) });
1356
+ }
1357
+ var content3 = { padding: "0 48px" };
1358
+ var heading3 = {
1359
+ fontSize: "24px",
1360
+ fontWeight: "bold",
1361
+ color: "#1a1a1a",
1362
+ margin: "0 0 16px"
1363
+ };
1364
+ var paragraph3 = {
1365
+ fontSize: "14px",
1366
+ lineHeight: "24px",
1367
+ color: "#525f7f",
1368
+ margin: "0 0 24px"
1369
+ };
1370
+ var button3 = {
1371
+ backgroundColor: "#1a1a1a",
1372
+ borderRadius: "6px",
1373
+ color: "#ffffff",
1374
+ fontSize: "14px",
1375
+ fontWeight: "600",
1376
+ textDecoration: "none",
1377
+ textAlign: "center",
1378
+ display: "block",
1379
+ padding: "12px 24px"
1380
+ };
1381
+ var hint3 = {
1382
+ fontSize: "12px",
1383
+ lineHeight: "20px",
1384
+ color: "#8898aa",
1385
+ margin: "24px 0 0"
1386
+ };
1387
+
1388
+ // src/server/mail/templates/WorkspaceInvite.tsx
1389
+ import { Button as Button4, Section as Section5, Text as Text5 } from "@react-email/components";
1390
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1391
+ function WorkspaceInvite({
1392
+ acceptUrl,
1393
+ inviterName,
1394
+ workspaceName,
1395
+ role,
1396
+ expiresInDays
1397
+ }) {
1398
+ return /* @__PURE__ */ jsx5(
1399
+ Layout,
1400
+ {
1401
+ preview: `${inviterName} invited you to ${workspaceName}`,
1402
+ appName: workspaceName,
1403
+ children: /* @__PURE__ */ jsxs5(Section5, { style: content4, children: [
1404
+ /* @__PURE__ */ jsx5(Text5, { style: heading4, children: "You've been invited" }),
1405
+ /* @__PURE__ */ jsxs5(Text5, { style: paragraph4, children: [
1406
+ inviterName,
1407
+ " invited you to join ",
1408
+ /* @__PURE__ */ jsx5("strong", { children: workspaceName }),
1409
+ " as a",
1410
+ " ",
1411
+ /* @__PURE__ */ jsx5("strong", { children: role }),
1412
+ "."
1413
+ ] }),
1414
+ /* @__PURE__ */ jsx5(Button4, { style: button4, href: acceptUrl, children: "Accept invitation" }),
1415
+ /* @__PURE__ */ jsxs5(Text5, { style: hint4, children: [
1416
+ "This invitation expires in ",
1417
+ expiresInDays,
1418
+ " ",
1419
+ expiresInDays === 1 ? "day" : "days",
1420
+ ". If you don't recognize this workspace, you can safely ignore this email."
1421
+ ] })
1422
+ ] })
1423
+ }
1424
+ );
1425
+ }
1426
+ var content4 = { padding: "0 48px" };
1427
+ var heading4 = {
1428
+ fontSize: "24px",
1429
+ fontWeight: "bold",
1430
+ color: "#1a1a1a",
1431
+ margin: "0 0 16px"
1432
+ };
1433
+ var paragraph4 = {
1434
+ fontSize: "14px",
1435
+ lineHeight: "24px",
1436
+ color: "#525f7f",
1437
+ margin: "0 0 24px"
1438
+ };
1439
+ var button4 = {
1440
+ backgroundColor: "#1a1a1a",
1441
+ borderRadius: "6px",
1442
+ color: "#ffffff",
1443
+ fontSize: "14px",
1444
+ fontWeight: "600",
1445
+ textDecoration: "none",
1446
+ textAlign: "center",
1447
+ display: "block",
1448
+ padding: "12px 24px"
1449
+ };
1450
+ var hint4 = {
1451
+ fontSize: "12px",
1452
+ lineHeight: "20px",
1453
+ color: "#8898aa",
1454
+ margin: "24px 0 0"
1455
+ };
1456
+
1457
+ // src/server/mail/templates/Welcome.tsx
1458
+ import { Button as Button5, Section as Section6, Text as Text6 } from "@react-email/components";
1459
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
1460
+ function Welcome({ appName, getStartedUrl }) {
1461
+ return /* @__PURE__ */ jsx6(Layout, { preview: `Welcome to ${appName}`, appName, children: /* @__PURE__ */ jsxs6(Section6, { style: content5, children: [
1462
+ /* @__PURE__ */ jsxs6(Text6, { style: heading5, children: [
1463
+ "Welcome to ",
1464
+ appName
1465
+ ] }),
1466
+ /* @__PURE__ */ jsx6(Text6, { style: paragraph5, children: "Your account is ready. Click below to get started." }),
1467
+ /* @__PURE__ */ jsx6(Button5, { style: button5, href: getStartedUrl, children: "Get started" })
1468
+ ] }) });
1469
+ }
1470
+ var content5 = { padding: "0 48px" };
1471
+ var heading5 = {
1472
+ fontSize: "24px",
1473
+ fontWeight: "bold",
1474
+ color: "#1a1a1a",
1475
+ margin: "0 0 16px"
1476
+ };
1477
+ var paragraph5 = {
1478
+ fontSize: "14px",
1479
+ lineHeight: "24px",
1480
+ color: "#525f7f",
1481
+ margin: "0 0 24px"
1482
+ };
1483
+ var button5 = {
1484
+ backgroundColor: "#1a1a1a",
1485
+ borderRadius: "6px",
1486
+ color: "#ffffff",
1487
+ fontSize: "14px",
1488
+ fontWeight: "600",
1489
+ textDecoration: "none",
1490
+ textAlign: "center",
1491
+ display: "block",
1492
+ padding: "12px 24px"
1493
+ };
1494
+
1495
+ // src/server/mail/templates/index.ts
1496
+ async function renderVerifyEmail(data) {
1497
+ const element = VerifyEmail({
1498
+ verifyUrl: data.verifyUrl,
1499
+ appName: data.appName,
1500
+ expiresInHours: data.expiresInHours
1501
+ });
1502
+ return {
1503
+ to: data.to,
1504
+ subject: `Verify your ${data.appName} email address`,
1505
+ html: await render(element),
1506
+ text: await render(element, { plainText: true })
1507
+ };
1508
+ }
1509
+ async function renderResetPassword(data) {
1510
+ const element = ResetPassword({
1511
+ resetUrl: data.resetUrl,
1512
+ appName: data.appName,
1513
+ expiresInHours: data.expiresInHours
1514
+ });
1515
+ return {
1516
+ to: data.to,
1517
+ subject: `Reset your ${data.appName} password`,
1518
+ html: await render(element),
1519
+ text: await render(element, { plainText: true })
1520
+ };
1521
+ }
1522
+ async function renderMagicLink(data) {
1523
+ const element = MagicLink({
1524
+ loginUrl: data.loginUrl,
1525
+ appName: data.appName,
1526
+ expiresInMinutes: data.expiresInMinutes
1527
+ });
1528
+ return {
1529
+ to: data.to,
1530
+ subject: `Sign in to ${data.appName}`,
1531
+ html: await render(element),
1532
+ text: await render(element, { plainText: true })
1533
+ };
1534
+ }
1535
+ async function renderWorkspaceInvite(data) {
1536
+ const element = WorkspaceInvite({
1537
+ acceptUrl: data.acceptUrl,
1538
+ inviterName: data.inviterName,
1539
+ workspaceName: data.workspaceName,
1540
+ role: data.role,
1541
+ expiresInDays: data.expiresInDays
1542
+ });
1543
+ return {
1544
+ to: data.to,
1545
+ subject: `${data.inviterName} invited you to ${data.workspaceName}`,
1546
+ html: await render(element),
1547
+ text: await render(element, { plainText: true })
1548
+ };
1549
+ }
1550
+ async function renderWelcome(data) {
1551
+ const element = Welcome({
1552
+ appName: data.appName,
1553
+ getStartedUrl: data.getStartedUrl
1554
+ });
1555
+ return {
1556
+ to: data.to,
1557
+ subject: `Welcome to ${data.appName}`,
1558
+ html: await render(element),
1559
+ text: await render(element, { plainText: true })
1560
+ };
1561
+ }
1562
+
1563
+ // src/server/auth/postSignupHook.ts
1564
+ import { createHash } from "crypto";
1565
+ function readHeader(ctx, name) {
1566
+ if (!ctx) return null;
1567
+ const fromGetHeader = ctx.getHeader?.(name);
1568
+ if (fromGetHeader) return fromGetHeader;
1569
+ const ctxAny = ctx;
1570
+ const req = ctxAny.request;
1571
+ return req?.headers?.get?.(name) ?? null;
1572
+ }
1573
+ function createPostSignupHook(deps) {
1574
+ const { config, workspaceStore, transport, logger } = deps;
1575
+ return async function postSignupHook(user, rawCtx) {
1576
+ const ctx = rawCtx;
1577
+ const inviteToken = readHeader(ctx, "x-invite-token");
1578
+ let inviteAccepted = false;
1579
+ if (inviteToken) {
1580
+ try {
1581
+ const failureCode = await tryAcceptInvite(user, inviteToken);
1582
+ if (failureCode) {
1583
+ logger?.warn(
1584
+ { userId: user.id, email: user.email, code: failureCode },
1585
+ "post-signup invite acceptance failed"
1586
+ );
1587
+ ctx?.setCookie?.("boring_invite_failed", failureCode, {
1588
+ maxAge: 60,
1589
+ path: "/",
1590
+ httpOnly: false,
1591
+ sameSite: "lax"
1592
+ });
1593
+ } else {
1594
+ inviteAccepted = true;
1595
+ }
1596
+ } catch (err) {
1597
+ logger?.warn(
1598
+ { userId: user.id, email: user.email, error: err instanceof Error ? err.message : String(err) },
1599
+ "post-signup invite acceptance threw unexpectedly"
1600
+ );
1601
+ }
1602
+ }
1603
+ if (!inviteAccepted) {
1604
+ await workspaceStore.create(user.id, "My Workspace", config.appId, { isDefault: true });
1605
+ }
1606
+ if (!inviteAccepted && config.features.sendWelcomeEmail !== false && transport) {
1607
+ const getStartedUrl = `${config.auth.url}/`;
1608
+ try {
1609
+ const email = await renderWelcome({
1610
+ to: user.email,
1611
+ appName: config.appName,
1612
+ getStartedUrl
1613
+ });
1614
+ await transport.send(email);
1615
+ } catch (err) {
1616
+ logger?.warn(
1617
+ { userId: user.id, err },
1618
+ "failed to send welcome email"
1619
+ );
1620
+ }
1621
+ }
1622
+ };
1623
+ async function tryAcceptInvite(user, rawToken) {
1624
+ const tokenHash = createHash("sha256").update(rawToken).digest("hex");
1625
+ const invite = await workspaceStore.getInviteByTokenHash(tokenHash);
1626
+ if (!invite) return "invite_not_found";
1627
+ if (invite.lockedUntil && new Date(invite.lockedUntil) > /* @__PURE__ */ new Date()) return "invite_not_found";
1628
+ if (invite.acceptedAt) return "invite_already_accepted";
1629
+ if (new Date(invite.expiresAt) <= /* @__PURE__ */ new Date()) return "invite_expired";
1630
+ if (invite.email.toLowerCase() !== user.email.toLowerCase()) return "invite_email_mismatch";
1631
+ await workspaceStore.acceptInvite(invite.workspaceId, invite.id, user.id);
1632
+ return null;
1633
+ }
1634
+ }
1635
+
1636
+ // src/server/auth/createAuth.ts
1637
+ import { betterAuth, APIError } from "better-auth";
1638
+ import { drizzleAdapter } from "better-auth/adapters/drizzle";
1639
+ import { magicLink } from "better-auth/plugins/magic-link";
1640
+ import { zxcvbn, zxcvbnOptions } from "@zxcvbn-ts/core";
1641
+ import * as zxcvbnCommon from "@zxcvbn-ts/language-common";
1642
+ import * as zxcvbnEn from "@zxcvbn-ts/language-en";
1643
+ var MIN_ZXCVBN_SCORE = 2;
1644
+ var zxcvbnInitialized = false;
1645
+ function ensureZxcvbn() {
1646
+ if (zxcvbnInitialized) return;
1647
+ zxcvbnOptions.setOptions({
1648
+ translations: zxcvbnEn.translations,
1649
+ graphs: zxcvbnCommon.adjacencyGraphs,
1650
+ dictionary: {
1651
+ ...zxcvbnCommon.dictionary,
1652
+ ...zxcvbnEn.dictionary
1653
+ }
1654
+ });
1655
+ zxcvbnInitialized = true;
1656
+ }
1657
+ function validatePasswordStrength(password) {
1658
+ ensureZxcvbn();
1659
+ const result = zxcvbn(password);
1660
+ if (result.score < MIN_ZXCVBN_SCORE) {
1661
+ return {
1662
+ valid: false,
1663
+ message: "This password is too common. Please choose another."
1664
+ };
1665
+ }
1666
+ return { valid: true };
1667
+ }
1668
+ function buildMailTransport(config) {
1669
+ if (!config.auth.mail) return null;
1670
+ const env = process.env.NODE_ENV === "production" ? "production" : process.env.NODE_ENV === "test" ? "test" : "development";
1671
+ return createMailTransport(config.auth.mail.transportUrl, config.auth.mail.from, env);
1672
+ }
1673
+ function createAuth(config, db, opts) {
1674
+ const transport = buildMailTransport(config);
1675
+ const emailVerificationConfig = transport ? {
1676
+ sendOnSignUp: true,
1677
+ sendVerificationEmail: async (data) => {
1678
+ const email = await renderVerifyEmail({
1679
+ to: data.user.email,
1680
+ verifyUrl: data.url,
1681
+ appName: config.appName,
1682
+ expiresInHours: 24
1683
+ });
1684
+ await transport.send(email);
1685
+ }
1686
+ } : void 0;
1687
+ const sendResetPasswordFn = transport ? async (data) => {
1688
+ const email = await renderResetPassword({
1689
+ to: data.user.email,
1690
+ resetUrl: data.url,
1691
+ appName: config.appName,
1692
+ expiresInHours: 1
1693
+ });
1694
+ await transport.send(email);
1695
+ } : void 0;
1696
+ const plugins = transport ? [
1697
+ magicLink({
1698
+ sendMagicLink: async (data) => {
1699
+ const email = await renderMagicLink({
1700
+ to: data.email,
1701
+ loginUrl: data.url,
1702
+ appName: config.appName,
1703
+ expiresInMinutes: 10
1704
+ });
1705
+ await transport.send(email);
1706
+ }
1707
+ })
1708
+ ] : [];
1709
+ const postSignupHook = opts?.workspaceStore ? createPostSignupHook({
1710
+ config,
1711
+ workspaceStore: opts.workspaceStore,
1712
+ transport,
1713
+ logger: opts.logger
1714
+ }) : void 0;
1715
+ return betterAuth({
1716
+ database: drizzleAdapter(db, { provider: "pg", schema: schema_exports }),
1717
+ secret: config.auth.secret,
1718
+ baseURL: config.auth.url,
1719
+ basePath: "/auth",
1720
+ trustedOrigins: config.cors.origins,
1721
+ databaseHooks: postSignupHook ? {
1722
+ user: {
1723
+ create: {
1724
+ after: postSignupHook
1725
+ }
1726
+ }
1727
+ } : void 0,
1728
+ advanced: {
1729
+ database: {
1730
+ generateId: "uuid"
1731
+ },
1732
+ cookiePrefix: config.appId,
1733
+ useSecureCookies: config.auth.sessionCookieSecure
1734
+ },
1735
+ user: {
1736
+ modelName: "users",
1737
+ fields: {
1738
+ emailVerified: "email_verified",
1739
+ createdAt: "created_at",
1740
+ updatedAt: "updated_at"
1741
+ }
1742
+ },
1743
+ session: {
1744
+ modelName: "sessions",
1745
+ expiresIn: config.auth.sessionTtlSeconds,
1746
+ fields: {
1747
+ expiresAt: "expires_at",
1748
+ ipAddress: "ip_address",
1749
+ userAgent: "user_agent",
1750
+ createdAt: "created_at",
1751
+ updatedAt: "updated_at"
1752
+ }
1753
+ },
1754
+ account: {
1755
+ modelName: "accounts",
1756
+ fields: {
1757
+ accountId: "account_id",
1758
+ providerId: "provider_id",
1759
+ accessToken: "access_token",
1760
+ refreshToken: "refresh_token",
1761
+ accessTokenExpiresAt: "access_token_expires_at",
1762
+ refreshTokenExpiresAt: "refresh_token_expires_at",
1763
+ idToken: "id_token",
1764
+ createdAt: "created_at",
1765
+ updatedAt: "updated_at"
1766
+ }
1767
+ },
1768
+ verification: {
1769
+ modelName: "verification_tokens",
1770
+ fields: {
1771
+ expiresAt: "expires_at",
1772
+ createdAt: "created_at",
1773
+ updatedAt: "updated_at"
1774
+ }
1775
+ },
1776
+ emailAndPassword: {
1777
+ enabled: true,
1778
+ minPasswordLength: 8,
1779
+ sendResetPassword: sendResetPasswordFn,
1780
+ password: {
1781
+ async hash(password) {
1782
+ const check = validatePasswordStrength(password);
1783
+ if (!check.valid) {
1784
+ throw APIError.from("BAD_REQUEST", {
1785
+ message: check.message,
1786
+ code: "WEAK_PASSWORD"
1787
+ });
1788
+ }
1789
+ const { hashPassword } = await import("better-auth/crypto");
1790
+ return hashPassword(password);
1791
+ }
1792
+ }
1793
+ },
1794
+ emailVerification: emailVerificationConfig,
1795
+ socialProviders: config.auth.github ? { github: { clientId: config.auth.github.clientId, clientSecret: config.auth.github.clientSecret } } : {},
1796
+ plugins
1797
+ });
1798
+ }
1799
+
1800
+ // src/server/auth/authHook.ts
1801
+ import fp2 from "fastify-plugin";
1802
+ var DEFAULT_PUBLIC = [
1803
+ /^\/auth\//,
1804
+ /^\/health$/,
1805
+ /^\/api\/v1\/config$/
1806
+ ];
1807
+ var authHookPlugin = async (app, opts) => {
1808
+ const publicPatterns = opts.public ?? DEFAULT_PUBLIC;
1809
+ app.addHook("onRequest", async (request, _reply) => {
1810
+ request.user = null;
1811
+ if (app.auth) {
1812
+ try {
1813
+ const headers = new Headers();
1814
+ for (const [key, val] of Object.entries(request.headers)) {
1815
+ if (val) headers.set(key, Array.isArray(val) ? val[0] : val);
1816
+ }
1817
+ const result = await app.auth.api.getSession({ headers });
1818
+ if (result?.user) {
1819
+ request.user = {
1820
+ id: result.user.id,
1821
+ email: result.user.email,
1822
+ name: result.user.name ?? null
1823
+ };
1824
+ }
1825
+ } catch {
1826
+ request.user = null;
1827
+ }
1828
+ }
1829
+ const path = request.url.split("?")[0];
1830
+ if (path.startsWith("/api/v1/") && !publicPatterns.some((re) => re.test(path)) && !request.user) {
1831
+ throw new HttpError({
1832
+ status: 401,
1833
+ code: ERROR_CODES.UNAUTHORIZED,
1834
+ message: "Authentication required",
1835
+ requestId: request.id
1836
+ });
1837
+ }
1838
+ });
1839
+ };
1840
+ var authHook = fp2(authHookPlugin, { name: "auth-hook" });
1841
+
1842
+ // src/server/auth/requireWorkspaceMember.ts
1843
+ var ROLE_LEVELS = {
1844
+ viewer: 0,
1845
+ editor: 1,
1846
+ owner: 2
1847
+ };
1848
+ var WORKSPACE_ID_RE = /^[A-Za-z0-9_-]{1,128}$/;
1849
+ function requireWorkspaceMember(minimumRole) {
1850
+ return async function(request, _reply) {
1851
+ const user = request.user;
1852
+ if (!user) {
1853
+ throw new Error(
1854
+ "requireWorkspaceMember: request.user is null \u2014 authHook must run before this hook"
1855
+ );
1856
+ }
1857
+ const workspaceId = request.params.id;
1858
+ if (!workspaceId) {
1859
+ throw new Error(
1860
+ "requireWorkspaceMember: missing :id param \u2014 route must include :id"
1861
+ );
1862
+ }
1863
+ if (!WORKSPACE_ID_RE.test(workspaceId)) {
1864
+ throw new HttpError({
1865
+ status: 400,
1866
+ code: ERROR_CODES.VALIDATION_FAILED,
1867
+ message: "Invalid workspace id",
1868
+ requestId: request.id
1869
+ });
1870
+ }
1871
+ const workspace = await request.server.workspaceStore.get(workspaceId);
1872
+ if (!workspace || workspace.appId !== request.server.config.appId) {
1873
+ throw new HttpError({
1874
+ status: 404,
1875
+ code: ERROR_CODES.NOT_FOUND,
1876
+ message: "Workspace not found",
1877
+ requestId: request.id
1878
+ });
1879
+ }
1880
+ const role = await request.server.workspaceStore.getMemberRole(
1881
+ workspaceId,
1882
+ user.id
1883
+ );
1884
+ if (!role) {
1885
+ throw new HttpError({
1886
+ status: 403,
1887
+ code: ERROR_CODES.NOT_MEMBER,
1888
+ message: "Not a member of this workspace",
1889
+ requestId: request.id
1890
+ });
1891
+ }
1892
+ if (minimumRole && ROLE_LEVELS[role] < ROLE_LEVELS[minimumRole]) {
1893
+ throw new HttpError({
1894
+ status: 403,
1895
+ code: ERROR_CODES.FORBIDDEN,
1896
+ message: `Requires ${minimumRole} role or higher`,
1897
+ requestId: request.id
1898
+ });
1899
+ }
1900
+ };
1901
+ }
1902
+
1903
+ // src/server/routes/workspaces.ts
1904
+ import fp3 from "fastify-plugin";
1905
+
1906
+ // src/server/routes/__schemas__/workspaces.ts
1907
+ import { z as z3 } from "zod";
1908
+ var createWorkspaceBody = z3.object({
1909
+ name: z3.string().min(1, "Name is required").max(100, "Name must be 100 characters or fewer")
1910
+ }).strict();
1911
+ var updateWorkspaceBody = z3.object({
1912
+ name: z3.string().min(1, "Name is required").max(100, "Name must be 100 characters or fewer").optional()
1913
+ }).strict();
1914
+
1915
+ // src/server/routes/workspaces.ts
1916
+ var workspaceRoutesPlugin = async (app) => {
1917
+ const store = app.workspaceStore;
1918
+ const provisioner = app.provisioner;
1919
+ app.post("/api/v1/workspaces", async (request, reply) => {
1920
+ const parsed = createWorkspaceBody.safeParse(request.body);
1921
+ if (!parsed.success) {
1922
+ throw new HttpError({
1923
+ status: 400,
1924
+ code: ERROR_CODES.VALIDATION_FAILED,
1925
+ message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
1926
+ requestId: request.id
1927
+ });
1928
+ }
1929
+ const user = request.user;
1930
+ const existing = await store.list(user.id, app.config.appId);
1931
+ const isDefault = existing.length === 0;
1932
+ const workspace = await store.create(user.id, parsed.data.name, app.config.appId, { isDefault });
1933
+ if (provisioner) {
1934
+ await store.putWorkspaceRuntime(workspace.id, { state: "pending" });
1935
+ try {
1936
+ const result = await provisioner.provision({
1937
+ workspaceId: workspace.id,
1938
+ workspaceName: workspace.name,
1939
+ ownerId: user.id,
1940
+ appId: app.config.appId
1941
+ });
1942
+ await store.putWorkspaceRuntime(workspace.id, {
1943
+ state: "ready",
1944
+ volumePath: result.volumePath
1945
+ });
1946
+ } catch (err) {
1947
+ const message = err instanceof Error ? err.message : String(err);
1948
+ await store.putWorkspaceRuntime(workspace.id, {
1949
+ state: "error",
1950
+ lastError: message,
1951
+ lastErrorOp: "provision"
1952
+ });
1953
+ request.log.error({ workspaceId: workspace.id, err }, "workspace.provision.failed");
1954
+ throw new HttpError({
1955
+ status: 500,
1956
+ code: ERROR_CODES.PROVISION_FAILED,
1957
+ message: "Workspace provisioning failed",
1958
+ requestId: request.id
1959
+ });
1960
+ }
1961
+ }
1962
+ request.log.info({ workspaceId: workspace.id, userId: user.id }, "workspace.create");
1963
+ reply.status(201);
1964
+ return { workspace, role: "owner" };
1965
+ });
1966
+ app.get("/api/v1/workspaces", async (request) => {
1967
+ const workspaces2 = await store.list(request.user.id, app.config.appId);
1968
+ return { workspaces: workspaces2 };
1969
+ });
1970
+ app.get(
1971
+ "/api/v1/workspaces/:id",
1972
+ { preHandler: requireWorkspaceMember() },
1973
+ async (request) => {
1974
+ const { id } = request.params;
1975
+ const workspace = await store.get(id);
1976
+ if (!workspace) {
1977
+ throw new HttpError({
1978
+ status: 404,
1979
+ code: ERROR_CODES.NOT_FOUND,
1980
+ message: "Workspace not found",
1981
+ requestId: request.id
1982
+ });
1983
+ }
1984
+ const role = await store.getMemberRole(id, request.user.id);
1985
+ return { workspace, role };
1986
+ }
1987
+ );
1988
+ app.put(
1989
+ "/api/v1/workspaces/:id",
1990
+ { preHandler: requireWorkspaceMember("editor") },
1991
+ async (request) => {
1992
+ const { id } = request.params;
1993
+ const parsed = updateWorkspaceBody.safeParse(request.body);
1994
+ if (!parsed.success) {
1995
+ throw new HttpError({
1996
+ status: 400,
1997
+ code: ERROR_CODES.VALIDATION_FAILED,
1998
+ message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
1999
+ requestId: request.id
2000
+ });
2001
+ }
2002
+ if (Object.keys(parsed.data).length === 0) {
2003
+ throw new HttpError({
2004
+ status: 400,
2005
+ code: ERROR_CODES.VALIDATION_FAILED,
2006
+ message: "At least one field must be provided",
2007
+ requestId: request.id
2008
+ });
2009
+ }
2010
+ const workspace = await store.update(id, parsed.data);
2011
+ if (!workspace) {
2012
+ throw new HttpError({
2013
+ status: 404,
2014
+ code: ERROR_CODES.NOT_FOUND,
2015
+ message: "Workspace not found",
2016
+ requestId: request.id
2017
+ });
2018
+ }
2019
+ request.log.info({ workspaceId: id }, "workspace.update");
2020
+ return { workspace };
2021
+ }
2022
+ );
2023
+ app.delete(
2024
+ "/api/v1/workspaces/:id",
2025
+ { preHandler: requireWorkspaceMember("owner") },
2026
+ async (request) => {
2027
+ const { id } = request.params;
2028
+ request.log.info({ workspaceId: id }, "workspace.delete.start");
2029
+ if (provisioner) {
2030
+ try {
2031
+ await provisioner.destroy(id);
2032
+ } catch (err) {
2033
+ const message = err instanceof Error ? err.message : String(err);
2034
+ await store.putWorkspaceRuntime(id, {
2035
+ state: "error",
2036
+ lastError: message,
2037
+ lastErrorOp: "destroy"
2038
+ });
2039
+ request.log.error({ workspaceId: id, err }, "workspace.destroy.failed");
2040
+ throw new HttpError({
2041
+ status: 500,
2042
+ code: ERROR_CODES.DESTROY_FAILED,
2043
+ message: "Workspace destruction failed",
2044
+ requestId: request.id
2045
+ });
2046
+ }
2047
+ }
2048
+ const result = await store.delete(id);
2049
+ if (result.removed) {
2050
+ request.log.info({ workspaceId: id }, "workspace.delete.complete");
2051
+ return { deleted: true };
2052
+ }
2053
+ throw new HttpError({
2054
+ status: 404,
2055
+ code: ERROR_CODES.NOT_FOUND,
2056
+ message: "Workspace not found",
2057
+ requestId: request.id
2058
+ });
2059
+ }
2060
+ );
2061
+ };
2062
+ var registerWorkspaceRoutes = fp3(workspaceRoutesPlugin, { name: "workspace-routes" });
2063
+
2064
+ // src/server/routes/members.ts
2065
+ import fp4 from "fastify-plugin";
2066
+
2067
+ // src/server/routes/__schemas__/members.ts
2068
+ import { z as z4 } from "zod";
2069
+ var addMemberBody = z4.object({
2070
+ userId: z4.string().uuid("userId must be a valid UUID"),
2071
+ role: z4.enum(["owner", "editor", "viewer"])
2072
+ }).strict();
2073
+ var updateRoleBody = z4.object({
2074
+ role: z4.enum(["owner", "editor", "viewer"])
2075
+ }).strict();
2076
+
2077
+ // src/server/routes/members.ts
2078
+ var memberRoutesPlugin = async (app) => {
2079
+ const store = app.workspaceStore;
2080
+ app.get(
2081
+ "/api/v1/workspaces/:id/members",
2082
+ { preHandler: requireWorkspaceMember() },
2083
+ async (request) => {
2084
+ const { id } = request.params;
2085
+ const members = await store.listMembers(id);
2086
+ return { members };
2087
+ }
2088
+ );
2089
+ app.post(
2090
+ "/api/v1/workspaces/:id/members",
2091
+ { preHandler: requireWorkspaceMember("owner") },
2092
+ async (request, reply) => {
2093
+ const { id } = request.params;
2094
+ const parsed = addMemberBody.safeParse(request.body);
2095
+ if (!parsed.success) {
2096
+ throw new HttpError({
2097
+ status: 400,
2098
+ code: ERROR_CODES.VALIDATION_FAILED,
2099
+ message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
2100
+ requestId: request.id
2101
+ });
2102
+ }
2103
+ const existing = await store.getMemberRole(id, parsed.data.userId);
2104
+ if (existing) {
2105
+ throw new HttpError({
2106
+ status: 409,
2107
+ code: ERROR_CODES.VALIDATION_FAILED,
2108
+ message: "User is already a member of this workspace",
2109
+ requestId: request.id
2110
+ });
2111
+ }
2112
+ const member = await store.upsertMember(id, parsed.data.userId, parsed.data.role);
2113
+ reply.status(201);
2114
+ return { member };
2115
+ }
2116
+ );
2117
+ app.patch(
2118
+ "/api/v1/workspaces/:id/members/:userId/role",
2119
+ { preHandler: requireWorkspaceMember("owner") },
2120
+ async (request) => {
2121
+ const { id, userId } = request.params;
2122
+ const parsed = updateRoleBody.safeParse(request.body);
2123
+ if (!parsed.success) {
2124
+ throw new HttpError({
2125
+ status: 400,
2126
+ code: ERROR_CODES.VALIDATION_FAILED,
2127
+ message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
2128
+ requestId: request.id
2129
+ });
2130
+ }
2131
+ const result = await store.updateMemberRole(id, userId, parsed.data.role);
2132
+ if (result.member) {
2133
+ return { member: result.member };
2134
+ }
2135
+ if (result.code === ERROR_CODES.LAST_OWNER) {
2136
+ throw new HttpError({
2137
+ status: 409,
2138
+ code: ERROR_CODES.LAST_OWNER,
2139
+ message: "Cannot demote the last owner",
2140
+ requestId: request.id
2141
+ });
2142
+ }
2143
+ if (result.code === ERROR_CODES.NOT_MEMBER) {
2144
+ throw new HttpError({
2145
+ status: 404,
2146
+ code: ERROR_CODES.NOT_MEMBER,
2147
+ message: "User is not a member of this workspace",
2148
+ requestId: request.id
2149
+ });
2150
+ }
2151
+ throw new HttpError({
2152
+ status: 500,
2153
+ code: ERROR_CODES.INTERNAL_ERROR,
2154
+ message: "Unexpected error updating role",
2155
+ requestId: request.id
2156
+ });
2157
+ }
2158
+ );
2159
+ app.delete(
2160
+ "/api/v1/workspaces/:id/members/:userId",
2161
+ { preHandler: requireWorkspaceMember() },
2162
+ async (request) => {
2163
+ const { id, userId } = request.params;
2164
+ const isSelfRemoval = request.user?.id === userId;
2165
+ if (!isSelfRemoval) {
2166
+ const callerRole = await store.getMemberRole(id, request.user.id);
2167
+ if (callerRole !== "owner") {
2168
+ throw new HttpError({
2169
+ status: 403,
2170
+ code: ERROR_CODES.FORBIDDEN,
2171
+ message: "Only owners can remove other members",
2172
+ requestId: request.id
2173
+ });
2174
+ }
2175
+ }
2176
+ const result = await store.removeMember(id, userId);
2177
+ if (result.removed) {
2178
+ return { removed: true };
2179
+ }
2180
+ if (result.code === ERROR_CODES.LAST_OWNER) {
2181
+ throw new HttpError({
2182
+ status: 409,
2183
+ code: ERROR_CODES.LAST_OWNER,
2184
+ message: "Cannot remove the last owner",
2185
+ requestId: request.id
2186
+ });
2187
+ }
2188
+ if (result.code === ERROR_CODES.NOT_MEMBER) {
2189
+ throw new HttpError({
2190
+ status: 404,
2191
+ code: ERROR_CODES.NOT_MEMBER,
2192
+ message: "User is not a member of this workspace",
2193
+ requestId: request.id
2194
+ });
2195
+ }
2196
+ throw new HttpError({
2197
+ status: 500,
2198
+ code: ERROR_CODES.INTERNAL_ERROR,
2199
+ message: "Unexpected error removing member",
2200
+ requestId: request.id
2201
+ });
2202
+ }
2203
+ );
2204
+ };
2205
+ var registerMemberRoutes = fp4(memberRoutesPlugin, { name: "member-routes" });
2206
+
2207
+ // src/server/middleware/idempotency.ts
2208
+ import { eq as eq2, lt, sql as sql2 } from "drizzle-orm";
2209
+ function createDrizzleIdempotencyStore(db) {
2210
+ return {
2211
+ async sweep() {
2212
+ await db.delete(idempotencyKeys).where(
2213
+ lt(idempotencyKeys.createdAt, sql2`now() - interval '24 hours'`)
2214
+ );
2215
+ },
2216
+ async find(key) {
2217
+ const rows = await db.select({
2218
+ responseStatus: idempotencyKeys.responseStatus,
2219
+ responseBody: idempotencyKeys.responseBody
2220
+ }).from(idempotencyKeys).where(eq2(idempotencyKeys.key, key)).limit(1);
2221
+ return rows[0] ?? null;
2222
+ },
2223
+ async set(key, scope, status, body2) {
2224
+ await db.insert(idempotencyKeys).values({ key, scope, responseStatus: status, responseBody: body2 }).onConflictDoNothing();
2225
+ }
2226
+ };
2227
+ }
2228
+ var REQUEST_KEY = "__idempotencyKey";
2229
+ var REQUEST_SCOPE = "__idempotencyScope";
2230
+ function createIdempotencyMiddleware(store) {
2231
+ function guard(scope) {
2232
+ return async (request, reply) => {
2233
+ const key = request.headers["idempotency-key"];
2234
+ if (typeof key !== "string" || key.length === 0) return;
2235
+ const compositeKey = `${scope}:${key}`;
2236
+ await store.sweep();
2237
+ const existing = await store.find(compositeKey);
2238
+ if (existing) {
2239
+ reply.status(existing.responseStatus).send(existing.responseBody);
2240
+ return reply;
2241
+ }
2242
+ ;
2243
+ request[REQUEST_KEY] = compositeKey;
2244
+ request[REQUEST_SCOPE] = scope;
2245
+ };
2246
+ }
2247
+ async function onSendCapture(request, reply, payload) {
2248
+ const key = request[REQUEST_KEY];
2249
+ const scope = request[REQUEST_SCOPE];
2250
+ if (!key || !scope) return payload;
2251
+ if (typeof payload !== "string") {
2252
+ request.log.warn({ idempotencyKey: key }, "idempotency.skip-non-json");
2253
+ return payload;
2254
+ }
2255
+ let parsed;
2256
+ try {
2257
+ parsed = JSON.parse(payload);
2258
+ } catch {
2259
+ request.log.warn({ idempotencyKey: key }, "idempotency.skip-non-json");
2260
+ return payload;
2261
+ }
2262
+ await store.set(key, scope, reply.statusCode, parsed);
2263
+ return payload;
2264
+ }
2265
+ return { guard, onSendCapture };
2266
+ }
2267
+
2268
+ // src/server/routes/invites.ts
2269
+ import { createHash as createHash2 } from "crypto";
2270
+ import fp5 from "fastify-plugin";
2271
+
2272
+ // src/server/routes/__schemas__/invites.ts
2273
+ import { z as z5 } from "zod";
2274
+ var createInviteBody = z5.object({
2275
+ email: z5.string().email("email must be a valid email address"),
2276
+ role: z5.enum(["owner", "editor", "viewer"])
2277
+ }).strict();
2278
+ var acceptInviteQuery = z5.object({
2279
+ invite_token: z5.string().min(1, "invite_token is required")
2280
+ }).strict();
2281
+ var tokenBody = z5.object({
2282
+ token: z5.string().min(1, "token is required")
2283
+ }).strict();
2284
+
2285
+ // src/server/routes/invites.ts
2286
+ function buildTransport(config) {
2287
+ if (!config.auth.mail) return null;
2288
+ const env = process.env.NODE_ENV === "production" ? "production" : process.env.NODE_ENV === "test" ? "test" : "development";
2289
+ return createMailTransport(config.auth.mail.transportUrl, config.auth.mail.from, env);
2290
+ }
2291
+ var inviteRoutesPlugin = async (app, opts) => {
2292
+ const store = app.workspaceStore;
2293
+ const transport = buildTransport(app.config);
2294
+ const idempotencyStore = opts.idempotencyStore ?? (() => {
2295
+ const db = app.db;
2296
+ return db ? createDrizzleIdempotencyStore(db) : null;
2297
+ })();
2298
+ const idem = idempotencyStore ? createIdempotencyMiddleware(idempotencyStore) : null;
2299
+ if (idem) {
2300
+ app.addHook("onSend", idem.onSendCapture);
2301
+ }
2302
+ app.get(
2303
+ "/api/v1/workspaces/:id/invites",
2304
+ { preHandler: requireWorkspaceMember() },
2305
+ async (request) => {
2306
+ const { id } = request.params;
2307
+ const invites = await store.listInvites(id);
2308
+ return { invites };
2309
+ }
2310
+ );
2311
+ app.post(
2312
+ "/api/v1/workspaces/:id/invites",
2313
+ {
2314
+ preHandler: idem ? [requireWorkspaceMember("owner"), idem.guard("invites")] : requireWorkspaceMember("owner")
2315
+ },
2316
+ async (request, reply) => {
2317
+ const { id } = request.params;
2318
+ const parsed = createInviteBody.safeParse(request.body);
2319
+ if (!parsed.success) {
2320
+ throw new HttpError({
2321
+ status: 400,
2322
+ code: ERROR_CODES.VALIDATION_FAILED,
2323
+ message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
2324
+ requestId: request.id
2325
+ });
2326
+ }
2327
+ const { invite, rawToken } = await store.createInvite(
2328
+ id,
2329
+ parsed.data.email,
2330
+ parsed.data.role,
2331
+ request.user.id,
2332
+ { ttlDays: app.config.features.inviteTtlDays }
2333
+ );
2334
+ if (transport) {
2335
+ const workspace = await store.get(id);
2336
+ const acceptUrl = `${app.config.auth.url}/api/v1/workspaces/${id}/invites/${invite.id}/accept?invite_token=${rawToken}`;
2337
+ try {
2338
+ const email = await renderWorkspaceInvite({
2339
+ to: parsed.data.email,
2340
+ acceptUrl,
2341
+ inviterName: request.user.name ?? request.user.email,
2342
+ workspaceName: workspace?.name ?? "Workspace",
2343
+ role: parsed.data.role,
2344
+ expiresInDays: app.config.features.inviteTtlDays
2345
+ });
2346
+ await transport.send(email);
2347
+ } catch (err) {
2348
+ request.log.warn({ workspaceId: id, inviteId: invite.id, err }, "invite.email.send.failed");
2349
+ }
2350
+ }
2351
+ request.log.info({ workspaceId: id, inviteId: invite.id, email: parsed.data.email }, "invite.create");
2352
+ reply.status(201);
2353
+ if (!transport) {
2354
+ return { invite, warning: "mail_disabled" };
2355
+ }
2356
+ return { invite };
2357
+ }
2358
+ );
2359
+ app.post(
2360
+ "/api/v1/workspaces/:id/invites/:inviteId/accept",
2361
+ async (request) => {
2362
+ const { id, inviteId } = request.params;
2363
+ const user = request.user;
2364
+ if (!user) {
2365
+ throw new HttpError({
2366
+ status: 401,
2367
+ code: ERROR_CODES.UNAUTHORIZED,
2368
+ message: "Authentication required",
2369
+ requestId: request.id
2370
+ });
2371
+ }
2372
+ const parsed = acceptInviteQuery.safeParse(request.query);
2373
+ if (!parsed.success) {
2374
+ throw new HttpError({
2375
+ status: 400,
2376
+ code: ERROR_CODES.VALIDATION_FAILED,
2377
+ message: "invite_token query parameter is required",
2378
+ requestId: request.id
2379
+ });
2380
+ }
2381
+ const tokenHash = createHash2("sha256").update(parsed.data.invite_token).digest("hex");
2382
+ const invite = await store.getInviteByTokenHash(tokenHash);
2383
+ if (!invite || invite.id !== inviteId || invite.workspaceId !== id) {
2384
+ throw new HttpError({
2385
+ status: 404,
2386
+ code: ERROR_CODES.INVITE_NOT_FOUND,
2387
+ message: "Invite not found",
2388
+ requestId: request.id
2389
+ });
2390
+ }
2391
+ if (invite.lockedUntil && new Date(invite.lockedUntil) > /* @__PURE__ */ new Date()) {
2392
+ throw new HttpError({
2393
+ status: 423,
2394
+ code: ERROR_CODES.INVITE_LOCKED,
2395
+ message: "Invite token is locked",
2396
+ requestId: request.id
2397
+ });
2398
+ }
2399
+ if (invite.acceptedAt) {
2400
+ throw new HttpError({
2401
+ status: 410,
2402
+ code: ERROR_CODES.INVITE_ALREADY_ACCEPTED,
2403
+ message: "Invite already accepted",
2404
+ requestId: request.id
2405
+ });
2406
+ }
2407
+ if (new Date(invite.expiresAt) <= /* @__PURE__ */ new Date()) {
2408
+ await store.incrementInviteFailedAttempts(invite.id);
2409
+ throw new HttpError({
2410
+ status: 410,
2411
+ code: ERROR_CODES.INVITE_EXPIRED,
2412
+ message: "Invite expired",
2413
+ requestId: request.id
2414
+ });
2415
+ }
2416
+ try {
2417
+ const result = await store.acceptInvite(id, inviteId, user.id);
2418
+ await store.resetInviteFailedAttempts(invite.id);
2419
+ request.log.info({ workspaceId: id, inviteId, userId: user.id }, "invite.accept");
2420
+ return { invite: result.invite, member: result.member };
2421
+ } catch (err) {
2422
+ if (err instanceof HttpError) {
2423
+ if (err.code === ERROR_CODES.INVITE_EMAIL_MISMATCH) {
2424
+ await store.incrementInviteFailedAttempts(invite.id);
2425
+ }
2426
+ throw err;
2427
+ }
2428
+ throw err;
2429
+ }
2430
+ }
2431
+ );
2432
+ app.delete(
2433
+ "/api/v1/workspaces/:id/invites/:inviteId",
2434
+ { preHandler: requireWorkspaceMember("owner") },
2435
+ async (request) => {
2436
+ const { id, inviteId } = request.params;
2437
+ const revoked = await store.revokeInvite(id, inviteId);
2438
+ if (!revoked) {
2439
+ throw new HttpError({
2440
+ status: 404,
2441
+ code: ERROR_CODES.NOT_FOUND,
2442
+ message: "Invite not found",
2443
+ requestId: request.id
2444
+ });
2445
+ }
2446
+ request.log.info({ workspaceId: id, inviteId }, "invite.revoke");
2447
+ return { revoked: true };
2448
+ }
2449
+ );
2450
+ app.post("/api/v1/invites/resolve", async (request) => {
2451
+ const parsed = tokenBody.safeParse(request.body);
2452
+ if (!parsed.success) {
2453
+ throw new HttpError({
2454
+ status: 400,
2455
+ code: ERROR_CODES.VALIDATION_FAILED,
2456
+ message: "token is required",
2457
+ requestId: request.id
2458
+ });
2459
+ }
2460
+ const tokenHash = createHash2("sha256").update(parsed.data.token).digest("hex");
2461
+ const invite = await store.getInviteByTokenHash(tokenHash);
2462
+ if (!invite) {
2463
+ throw new HttpError({
2464
+ status: 404,
2465
+ code: ERROR_CODES.INVITE_NOT_FOUND,
2466
+ message: "Invite not found",
2467
+ requestId: request.id
2468
+ });
2469
+ }
2470
+ if (invite.lockedUntil && new Date(invite.lockedUntil) > /* @__PURE__ */ new Date()) {
2471
+ throw new HttpError({
2472
+ status: 423,
2473
+ code: ERROR_CODES.INVITE_LOCKED,
2474
+ message: "Invite token is locked",
2475
+ requestId: request.id
2476
+ });
2477
+ }
2478
+ if (invite.acceptedAt || new Date(invite.expiresAt) <= /* @__PURE__ */ new Date()) {
2479
+ await store.incrementInviteFailedAttempts(invite.id);
2480
+ throw new HttpError({
2481
+ status: 404,
2482
+ code: ERROR_CODES.INVITE_NOT_FOUND,
2483
+ message: "Invite not found",
2484
+ requestId: request.id
2485
+ });
2486
+ }
2487
+ const workspace = await store.get(invite.workspaceId);
2488
+ return {
2489
+ workspaceName: workspace?.name ?? "Workspace",
2490
+ role: invite.role,
2491
+ expiresAt: invite.expiresAt
2492
+ };
2493
+ });
2494
+ app.post("/api/v1/invites/accept", async (request) => {
2495
+ const user = request.user;
2496
+ if (!user) {
2497
+ throw new HttpError({
2498
+ status: 401,
2499
+ code: ERROR_CODES.UNAUTHORIZED,
2500
+ message: "Authentication required",
2501
+ requestId: request.id
2502
+ });
2503
+ }
2504
+ const parsed = tokenBody.safeParse(request.body);
2505
+ if (!parsed.success) {
2506
+ throw new HttpError({
2507
+ status: 400,
2508
+ code: ERROR_CODES.VALIDATION_FAILED,
2509
+ message: "token is required",
2510
+ requestId: request.id
2511
+ });
2512
+ }
2513
+ const tokenHash = createHash2("sha256").update(parsed.data.token).digest("hex");
2514
+ const invite = await store.getInviteByTokenHash(tokenHash);
2515
+ if (!invite) {
2516
+ throw new HttpError({
2517
+ status: 404,
2518
+ code: ERROR_CODES.INVITE_NOT_FOUND,
2519
+ message: "Invite not found",
2520
+ requestId: request.id
2521
+ });
2522
+ }
2523
+ if (invite.lockedUntil && new Date(invite.lockedUntil) > /* @__PURE__ */ new Date()) {
2524
+ throw new HttpError({
2525
+ status: 423,
2526
+ code: ERROR_CODES.INVITE_LOCKED,
2527
+ message: "Invite token is locked",
2528
+ requestId: request.id
2529
+ });
2530
+ }
2531
+ if (invite.acceptedAt) {
2532
+ await store.incrementInviteFailedAttempts(invite.id);
2533
+ throw new HttpError({
2534
+ status: 409,
2535
+ code: ERROR_CODES.INVITE_ALREADY_ACCEPTED,
2536
+ message: "Invite already accepted",
2537
+ requestId: request.id
2538
+ });
2539
+ }
2540
+ if (new Date(invite.expiresAt) <= /* @__PURE__ */ new Date()) {
2541
+ await store.incrementInviteFailedAttempts(invite.id);
2542
+ throw new HttpError({
2543
+ status: 410,
2544
+ code: ERROR_CODES.INVITE_EXPIRED,
2545
+ message: "Invite expired",
2546
+ requestId: request.id
2547
+ });
2548
+ }
2549
+ try {
2550
+ const result = await store.acceptInvite(invite.workspaceId, invite.id, user.id);
2551
+ await store.resetInviteFailedAttempts(invite.id);
2552
+ request.log.info({ inviteId: invite.id, userId: user.id }, "invite.token.accept");
2553
+ return { workspace: await store.get(invite.workspaceId), member: result.member };
2554
+ } catch (err) {
2555
+ if (err instanceof HttpError) {
2556
+ if (err.code === ERROR_CODES.INVITE_EMAIL_MISMATCH) {
2557
+ await store.incrementInviteFailedAttempts(invite.id);
2558
+ }
2559
+ throw err;
2560
+ }
2561
+ throw err;
2562
+ }
2563
+ });
2564
+ };
2565
+ var registerInviteRoutes = fp5(inviteRoutesPlugin, { name: "invite-routes" });
2566
+
2567
+ // src/server/routes/settings.ts
2568
+ import fp6 from "fastify-plugin";
2569
+
2570
+ // src/server/routes/__schemas__/settings.ts
2571
+ import { z as z6 } from "zod";
2572
+ var putSettingsBody = z6.record(
2573
+ z6.string().min(1, "Key must be at least 1 character").max(128, "Key must be 128 characters or fewer"),
2574
+ z6.string().min(1, "Value must not be empty").max(1e4, "Value must be 10000 characters or fewer")
2575
+ ).refine((obj) => Object.keys(obj).length > 0, { message: "At least one setting is required" }).refine((obj) => Object.keys(obj).length <= 50, { message: "Maximum 50 settings per request" });
2576
+
2577
+ // src/server/routes/settings.ts
2578
+ var settingsRoutesPlugin = async (app) => {
2579
+ const store = app.workspaceStore;
2580
+ app.get(
2581
+ "/api/v1/workspaces/:id/settings",
2582
+ { preHandler: requireWorkspaceMember() },
2583
+ async (request) => {
2584
+ const { id } = request.params;
2585
+ const settings = await store.getWorkspaceSettings(id);
2586
+ return { settings };
2587
+ }
2588
+ );
2589
+ app.put(
2590
+ "/api/v1/workspaces/:id/settings",
2591
+ { preHandler: requireWorkspaceMember("editor") },
2592
+ async (request) => {
2593
+ const { id } = request.params;
2594
+ const parsed = putSettingsBody.safeParse(request.body);
2595
+ if (!parsed.success) {
2596
+ throw new HttpError({
2597
+ status: 400,
2598
+ code: ERROR_CODES.VALIDATION_FAILED,
2599
+ message: parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "),
2600
+ requestId: request.id
2601
+ });
2602
+ }
2603
+ const settings = await store.putWorkspaceSettings(id, parsed.data);
2604
+ return { settings };
2605
+ }
2606
+ );
2607
+ app.get(
2608
+ "/api/v1/workspaces/:id/runtime",
2609
+ { preHandler: requireWorkspaceMember() },
2610
+ async (request) => {
2611
+ const { id } = request.params;
2612
+ const runtime = await store.getWorkspaceRuntime(id);
2613
+ if (!runtime) {
2614
+ throw new HttpError({
2615
+ status: 404,
2616
+ code: ERROR_CODES.NOT_FOUND,
2617
+ message: "Workspace not found",
2618
+ requestId: request.id
2619
+ });
2620
+ }
2621
+ return { runtime };
2622
+ }
2623
+ );
2624
+ app.post(
2625
+ "/api/v1/workspaces/:id/runtime/retry",
2626
+ { preHandler: requireWorkspaceMember("owner") },
2627
+ async (request) => {
2628
+ const { id } = request.params;
2629
+ const provisioner = app.provisioner;
2630
+ const runtime = await store.getWorkspaceRuntime(id);
2631
+ if (!runtime || !provisioner) {
2632
+ throw new HttpError({
2633
+ status: 409,
2634
+ code: ERROR_CODES.RUNTIME_UNMANAGED,
2635
+ message: "No managed runtime for this workspace",
2636
+ requestId: request.id
2637
+ });
2638
+ }
2639
+ if (runtime.state !== "error" || runtime.lastErrorOp !== "provision") {
2640
+ throw new HttpError({
2641
+ status: 409,
2642
+ code: ERROR_CODES.INVALID_RETRY_STATE,
2643
+ message: "Runtime must be in error state with last_error_op=provision to retry",
2644
+ requestId: request.id
2645
+ });
2646
+ }
2647
+ await store.putWorkspaceRuntime(id, { state: "pending", lastError: null, lastErrorOp: null });
2648
+ try {
2649
+ const ws = await store.get(id);
2650
+ const result = await provisioner.provision({
2651
+ workspaceId: id,
2652
+ workspaceName: ws?.name ?? id,
2653
+ ownerId: request.user.id,
2654
+ appId: app.config.appId
2655
+ });
2656
+ const updated = await store.putWorkspaceRuntime(id, {
2657
+ state: "ready",
2658
+ volumePath: result.volumePath
2659
+ });
2660
+ request.log.info({ workspaceId: id }, "workspace.provision.retry.success");
2661
+ return { runtime: updated };
2662
+ } catch (err) {
2663
+ const message = err instanceof Error ? err.message : String(err);
2664
+ await store.putWorkspaceRuntime(id, {
2665
+ state: "error",
2666
+ lastError: message,
2667
+ lastErrorOp: "provision"
2668
+ });
2669
+ request.log.error({ workspaceId: id, err }, "workspace.provision.retry.failed");
2670
+ throw new HttpError({
2671
+ status: 500,
2672
+ code: ERROR_CODES.PROVISION_FAILED,
2673
+ message: "Workspace provisioning failed",
2674
+ requestId: request.id
2675
+ });
2676
+ }
2677
+ }
2678
+ );
2679
+ };
2680
+ var registerSettingsRoutes = fp6(settingsRoutesPlugin, { name: "settings-routes" });
2681
+
2682
+ // src/server/runtime/WorkspaceRuntimeSandboxHandleStore.ts
2683
+ var SANDBOX_RESOURCE = {
2684
+ kind: "sandbox",
2685
+ purpose: "main",
2686
+ provider: "vercel"
2687
+ };
2688
+ var WorkspaceRuntimeSandboxHandleStore = class {
2689
+ constructor(store) {
2690
+ this.store = store;
2691
+ }
2692
+ store;
2693
+ async get(workspaceId) {
2694
+ const resource = await this.store.getWorkspaceRuntimeResource?.(workspaceId, SANDBOX_RESOURCE);
2695
+ const handle = resourceToHandle(resource);
2696
+ if (handle) return handle;
2697
+ const runtime = await this.store.getWorkspaceRuntime(workspaceId);
2698
+ return runtimeToHandle(runtime);
2699
+ }
2700
+ async put(record) {
2701
+ const seenAt = (/* @__PURE__ */ new Date()).toISOString();
2702
+ const selector = {
2703
+ ...SANDBOX_RESOURCE,
2704
+ provider: record.provider ?? SANDBOX_RESOURCE.provider
2705
+ };
2706
+ const previousResource = await this.store.getWorkspaceRuntimeResource?.(
2707
+ record.workspaceId,
2708
+ selector
2709
+ );
2710
+ let resourceWritten = false;
2711
+ if (this.store.putWorkspaceRuntimeResource) {
2712
+ await this.store.putWorkspaceRuntimeResource(
2713
+ record.workspaceId,
2714
+ handleToResourceInput(record, seenAt)
2715
+ );
2716
+ resourceWritten = true;
2717
+ }
2718
+ try {
2719
+ await this.store.putWorkspaceRuntime(record.workspaceId, {
2720
+ sandboxProvider: record.provider ?? "vercel",
2721
+ sandboxId: record.sandboxId,
2722
+ sandboxSnapshotId: record.snapshotId ?? null,
2723
+ sandboxCreatedAt: record.createdAt,
2724
+ sandboxLastUsedAt: record.lastUsedAt,
2725
+ sandboxLastSeenAt: seenAt,
2726
+ state: "ready",
2727
+ lastError: null,
2728
+ lastErrorOp: null
2729
+ });
2730
+ } catch (error) {
2731
+ if (resourceWritten) {
2732
+ await this.restoreRuntimeResource(record.workspaceId, selector, previousResource);
2733
+ }
2734
+ throw error;
2735
+ }
2736
+ }
2737
+ async restoreRuntimeResource(workspaceId, selector, previousResource) {
2738
+ try {
2739
+ if (previousResource && this.store.putWorkspaceRuntimeResource) {
2740
+ await this.store.putWorkspaceRuntimeResource(
2741
+ workspaceId,
2742
+ resourceToInput(previousResource)
2743
+ );
2744
+ return;
2745
+ }
2746
+ await this.store.deleteWorkspaceRuntimeResource?.(workspaceId, selector);
2747
+ } catch {
2748
+ }
2749
+ }
2750
+ async delete(workspaceId) {
2751
+ await this.store.deleteWorkspaceRuntimeResource?.(workspaceId, SANDBOX_RESOURCE);
2752
+ const existing = await this.store.getWorkspaceRuntime(workspaceId);
2753
+ if (!existing) return;
2754
+ await this.store.putWorkspaceRuntime(workspaceId, {
2755
+ sandboxId: null,
2756
+ sandboxStatus: null,
2757
+ sandboxSnapshotId: null,
2758
+ sandboxCreatedAt: null,
2759
+ sandboxLastUsedAt: null,
2760
+ sandboxLastSeenAt: null,
2761
+ sandboxExpiresAt: null
2762
+ });
2763
+ }
2764
+ async list() {
2765
+ if (this.store.listWorkspaceRuntimeResources) {
2766
+ const resources = await this.store.listWorkspaceRuntimeResources();
2767
+ const handles = resources.filter(
2768
+ (resource) => resource.kind === SANDBOX_RESOURCE.kind && resource.purpose === SANDBOX_RESOURCE.purpose && resource.provider === SANDBOX_RESOURCE.provider && resource.state !== "deleted"
2769
+ ).map((resource) => resourceToHandle(resource)).filter((record) => record !== null);
2770
+ if (handles.length > 0) return handles;
2771
+ }
2772
+ if (!this.store.listWorkspaceRuntimes) return [];
2773
+ const runtimes = await this.store.listWorkspaceRuntimes();
2774
+ return runtimes.map((runtime) => runtimeToHandle(runtime)).filter((record) => record !== null);
2775
+ }
2776
+ };
2777
+ function handleToResourceInput(record, seenAt) {
2778
+ return {
2779
+ ...SANDBOX_RESOURCE,
2780
+ provider: record.provider ?? SANDBOX_RESOURCE.provider,
2781
+ handleKind: record.handleKind ?? "session",
2782
+ stableKey: record.stableKey ?? null,
2783
+ providerResourceId: record.sandboxId,
2784
+ state: "ready",
2785
+ persistenceMode: record.persistenceMode ?? (record.snapshotId ? "snapshot" : "ephemeral"),
2786
+ providerMeta: {
2787
+ ...record.providerMeta ?? {},
2788
+ ...record.snapshotId ? { snapshotId: record.snapshotId } : {}
2789
+ },
2790
+ lastSeenAt: seenAt,
2791
+ lastUsedAt: record.lastUsedAt
2792
+ };
2793
+ }
2794
+ function resourceToInput(resource) {
2795
+ return {
2796
+ id: resource.id,
2797
+ kind: resource.kind,
2798
+ purpose: resource.purpose,
2799
+ provider: resource.provider,
2800
+ handleKind: resource.handleKind,
2801
+ stableKey: resource.stableKey,
2802
+ providerResourceId: resource.providerResourceId,
2803
+ parentResourceId: resource.parentResourceId,
2804
+ state: resource.state,
2805
+ persistenceMode: resource.persistenceMode,
2806
+ config: resource.config,
2807
+ providerMeta: resource.providerMeta,
2808
+ lastError: resource.lastError,
2809
+ lastErrorCode: resource.lastErrorCode,
2810
+ lastSeenAt: resource.lastSeenAt,
2811
+ lastUsedAt: resource.lastUsedAt,
2812
+ expiresAt: resource.expiresAt,
2813
+ generation: resource.generation
2814
+ };
2815
+ }
2816
+ function resourceToHandle(resource) {
2817
+ if (!resource?.providerResourceId || resource.state === "deleted") return null;
2818
+ const snapshotId = typeof resource.providerMeta.snapshotId === "string" ? resource.providerMeta.snapshotId : void 0;
2819
+ return {
2820
+ workspaceId: resource.workspaceId,
2821
+ sandboxId: resource.providerResourceId,
2822
+ snapshotId,
2823
+ provider: resource.provider,
2824
+ handleKind: resource.handleKind,
2825
+ stableKey: resource.stableKey ?? void 0,
2826
+ persistenceMode: resource.persistenceMode,
2827
+ providerMeta: resource.providerMeta,
2828
+ createdAt: resource.createdAt,
2829
+ lastUsedAt: resource.lastUsedAt ?? resource.updatedAt
2830
+ };
2831
+ }
2832
+ function runtimeToHandle(runtime) {
2833
+ if (!runtime?.sandboxId) return null;
2834
+ return {
2835
+ workspaceId: runtime.workspaceId,
2836
+ sandboxId: runtime.sandboxId,
2837
+ snapshotId: runtime.sandboxSnapshotId ?? void 0,
2838
+ createdAt: runtime.sandboxCreatedAt ?? runtime.updatedAt,
2839
+ lastUsedAt: runtime.sandboxLastUsedAt ?? runtime.updatedAt
2840
+ };
2841
+ }
2842
+
2843
+ export {
2844
+ coreConfigSchema,
2845
+ loadConfig,
2846
+ validateConfig,
2847
+ buildRuntimeConfigPayload,
2848
+ createCoreApp,
2849
+ registerRoutes,
2850
+ MailDeliveryError,
2851
+ createMailTransport,
2852
+ renderVerifyEmail,
2853
+ renderResetPassword,
2854
+ renderMagicLink,
2855
+ renderWorkspaceInvite,
2856
+ renderWelcome,
2857
+ createPostSignupHook,
2858
+ validatePasswordStrength,
2859
+ createAuth,
2860
+ authHook,
2861
+ requireWorkspaceMember,
2862
+ registerWorkspaceRoutes,
2863
+ registerMemberRoutes,
2864
+ createDrizzleIdempotencyStore,
2865
+ createIdempotencyMiddleware,
2866
+ registerInviteRoutes,
2867
+ registerSettingsRoutes,
2868
+ WorkspaceRuntimeSandboxHandleStore
2869
+ };