@maudecode/kanvil 0.0.0-stage → 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 (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +35 -2
  3. package/apps/server/.env.example +41 -0
  4. package/apps/server/dist/auth-BscXb1NQ.mjs +2666 -0
  5. package/apps/server/dist/config-Qxr_7oqZ.mjs +575 -0
  6. package/apps/server/dist/configure.mjs +23 -0
  7. package/apps/server/dist/index.mjs +1754 -0
  8. package/apps/server/dist/migrate-C9f4ozB4.mjs +17 -0
  9. package/apps/server/dist/migrate.mjs +12 -0
  10. package/apps/server/dist/recover.mjs +52 -0
  11. package/apps/server/drizzle/0000_foundation.sql +437 -0
  12. package/apps/server/drizzle/0001_claim_identity.sql +1 -0
  13. package/apps/server/drizzle/0002_transactional_attachments.sql +6 -0
  14. package/apps/server/drizzle/0003_durable_claim_fence.sql +4 -0
  15. package/apps/server/drizzle/0004_lumpy_archangel.sql +6 -0
  16. package/apps/server/drizzle/0005_unique_toxin.sql +28 -0
  17. package/apps/server/drizzle/0006_instance_services.sql +48 -0
  18. package/apps/server/drizzle/0007_optional_broker.sql +6 -0
  19. package/apps/server/drizzle/0008_workspaces.sql +37 -0
  20. package/apps/server/drizzle/0009_project_icons.sql +6 -0
  21. package/apps/server/drizzle/0010_project_labels.sql +14 -0
  22. package/apps/server/drizzle/0011_due_dates.sql +1 -0
  23. package/apps/server/drizzle/0012_label_colors.sql +7 -0
  24. package/apps/server/drizzle/meta/0000_snapshot.json +3210 -0
  25. package/apps/server/drizzle/meta/0001_snapshot.json +3216 -0
  26. package/apps/server/drizzle/meta/0002_snapshot.json +3255 -0
  27. package/apps/server/drizzle/meta/0003_snapshot.json +3262 -0
  28. package/apps/server/drizzle/meta/0004_snapshot.json +3302 -0
  29. package/apps/server/drizzle/meta/0005_snapshot.json +3518 -0
  30. package/apps/server/drizzle/meta/0006_snapshot.json +3842 -0
  31. package/apps/server/drizzle/meta/0007_snapshot.json +3874 -0
  32. package/apps/server/drizzle/meta/0008_snapshot.json +4051 -0
  33. package/apps/server/drizzle/meta/0009_snapshot.json +4105 -0
  34. package/apps/server/drizzle/meta/0010_snapshot.json +4174 -0
  35. package/apps/server/drizzle/meta/0011_snapshot.json +4180 -0
  36. package/apps/server/drizzle/meta/0012_snapshot.json +4226 -0
  37. package/apps/server/drizzle/meta/_journal.json +97 -0
  38. package/apps/web/dist/apple-touch-icon.png +0 -0
  39. package/apps/web/dist/assets/CompositeItem-D9KvUNiq.js +1 -0
  40. package/apps/web/dist/assets/admin-settings-B0u2-aAK.js +1 -0
  41. package/apps/web/dist/assets/camera-Cv5wKkKn.js +1 -0
  42. package/apps/web/dist/assets/date-picker-lhXxKIlN.js +1 -0
  43. package/apps/web/dist/assets/ellipsis-CN74O_EP.js +1 -0
  44. package/apps/web/dist/assets/forms-456UbfIC.js +92 -0
  45. package/apps/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  46. package/apps/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  47. package/apps/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  48. package/apps/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  49. package/apps/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  50. package/apps/web/dist/assets/index-CVzq9BMC.css +2 -0
  51. package/apps/web/dist/assets/index-Dt8wYIVA.js +51 -0
  52. package/apps/web/dist/assets/instance-BCNvOvYa.js +1 -0
  53. package/apps/web/dist/assets/list-order-1UoOT5nR.js +1 -0
  54. package/apps/web/dist/assets/lucide-U995NW8E.js +1 -0
  55. package/apps/web/dist/assets/member-directory-C81ugis6.js +1 -0
  56. package/apps/web/dist/assets/popover-BWk65ByY.js +1 -0
  57. package/apps/web/dist/assets/project-settings-DnpVfVf5.js +1 -0
  58. package/apps/web/dist/assets/rotate-ccw-BLeWpuu1.js +1 -0
  59. package/apps/web/dist/assets/settings-DUXuOgWn.js +1 -0
  60. package/apps/web/dist/assets/settings-layout-xR4hrfte.js +1 -0
  61. package/apps/web/dist/assets/table-BQbZG4BK.js +1 -0
  62. package/apps/web/dist/assets/ticket-detail-B9M_eaVu.js +2 -0
  63. package/apps/web/dist/assets/ticket-editor-Chg1lDuU.js +1 -0
  64. package/apps/web/dist/assets/ticket-list-C8500tXj.js +5 -0
  65. package/apps/web/dist/assets/ticket-tags-cbwQ93Gt.js +1 -0
  66. package/apps/web/dist/assets/upload-5Wg5ou_Z.js +1 -0
  67. package/apps/web/dist/assets/workspace-settings-Dte44Kkx.js +1 -0
  68. package/apps/web/dist/brand/icon-192.png +0 -0
  69. package/apps/web/dist/brand/icon-512.png +0 -0
  70. package/apps/web/dist/brand/mark-blue.svg +1 -0
  71. package/apps/web/dist/brand/mark-white.svg +1 -0
  72. package/apps/web/dist/brand/mark.svg +1 -0
  73. package/apps/web/dist/brand/maskable-512.png +0 -0
  74. package/apps/web/dist/brand/social.png +0 -0
  75. package/apps/web/dist/favicon.ico +0 -0
  76. package/apps/web/dist/favicon.svg +1 -0
  77. package/apps/web/dist/fonts/OFL.txt +93 -0
  78. package/apps/web/dist/fonts/ibm-plex-sans-latin.woff2 +0 -0
  79. package/apps/web/dist/icon.svg +1 -0
  80. package/apps/web/dist/index.html +31 -0
  81. package/apps/web/dist/site.webmanifest +29 -0
  82. package/apps/web/dist/third-party-icons.txt +64 -0
  83. package/apps/web/dist/third-party-themes.txt +548 -0
  84. package/bin/kanvil.mjs +27 -0
  85. package/compose.yml +66 -0
  86. package/docs/api.md +87 -0
  87. package/docs/branding.md +13 -0
  88. package/docs/design.md +37 -0
  89. package/docs/infrastructure.md +94 -0
  90. package/docs/mobile.md +79 -0
  91. package/docs/plan.md +111 -0
  92. package/docs/releasing.md +18 -0
  93. package/docs/self-hosting.md +204 -0
  94. package/docs/themes.md +38 -0
  95. package/docs/verification.md +83 -0
  96. package/docs/workspaces.md +21 -0
  97. package/package.json +45 -4
@@ -0,0 +1,1754 @@
1
+ import { a as photoInputSchema, c as searchAuthentication, d as account, f as apikey, h as user, i as instanceUpdateSchema, l as searchSettingsSchema, m as session, n as adminInstanceSchema, o as photoResultSchema, r as brokerSettingsSchema, s as s3SettingsSchema, t as readConfig, u as createDatabase } from "./config-Qxr_7oqZ.mjs";
2
+ import { t as migrateDatabase } from "./migrate-C9f4ozB4.mjs";
3
+ import { A as storageBackends, B as notificationPreferencesInput, C as members, D as projects, E as profilePhotos, F as ApiError, G as roles, H as okSchema, I as errorSchema, K as scopes, L as hasAdminRole, M as tickets, N as workspaceMembers, O as pushDeliveries, P as workspaces, R as isOperation, S as instanceSettings, T as notifications, U as operations, V as notificationPreferencesSchema, W as pushDeviceInput, _ as attachments, a as json, b as eventClock, c as projectRoleSQL, d as createBlobStorage, f as decodeImage, g as attachmentContent, h as createPollingJob, i as fail, j as subscriptions, k as pushDevices, l as createTicketSearch, m as createObjectStore, n as assertVersion, o as one, p as imageResponse, r as createDomain, s as requireRow, t as createAuth, u as createTicketIndex, v as blobObjects, w as notificationPreferences, x as events, y as deliveryCheckpoints, z as memberSchema } from "./auth-BscXb1NQ.mjs";
4
+ import { serve } from "@hono/node-server";
5
+ import pg from "pg";
6
+ import { and, asc, count, desc, eq, gt, ilike, inArray, isNull, lte, or, sql } from "drizzle-orm";
7
+ import { z } from "zod";
8
+ import { fileURLToPath } from "node:url";
9
+ import { createCipheriv, createDecipheriv, createHash, randomBytes, timingSafeEqual } from "node:crypto";
10
+ import { isDeepStrictEqual } from "node:util";
11
+ import { EventEmitter } from "node:events";
12
+ import { connect } from "amqplib";
13
+ import { OpenAPIHono, createRoute, z as z$1 } from "@hono/zod-openapi";
14
+ import { HTTPException } from "hono/http-exception";
15
+ import { bodyLimit } from "hono/body-limit";
16
+ import { secureHeaders } from "hono/secure-headers";
17
+ import { streamSSE } from "hono/streaming";
18
+ import { serveStatic } from "@hono/node-server/serve-static";
19
+ import { APIError } from "better-auth/api";
20
+ import { requireMcpAuth } from "@better-auth/mcp";
21
+ import { Expo } from "expo-server-sdk";
22
+ import { McpServer, createMcpHandler } from "@modelcontextprotocol/server";
23
+ //#region ../../packages/contracts/src/users.ts
24
+ const accountSchema = memberSchema.pick({
25
+ name: true,
26
+ email: true,
27
+ image: true
28
+ }).extend({
29
+ id: z.string(),
30
+ role: z.string().nullable(),
31
+ banned: z.boolean(),
32
+ createdAt: z.iso.datetime()
33
+ });
34
+ const accountQuerySchema = z.object({
35
+ q: z.string().trim().max(200).default(""),
36
+ role: z.enum([
37
+ "all",
38
+ "user",
39
+ "admin"
40
+ ]).default("all"),
41
+ status: z.enum([
42
+ "all",
43
+ "active",
44
+ "disabled"
45
+ ]).default("all"),
46
+ limit: z.coerce.number().int().min(1).max(100).default(25),
47
+ offset: z.coerce.number().int().min(0).default(0)
48
+ });
49
+ const accountsSchema = z.object({
50
+ users: z.array(accountSchema),
51
+ total: z.number().int().nonnegative()
52
+ });
53
+ const accountDetailsSchema = accountSchema.extend({
54
+ workspaces: z.array(z.object({
55
+ id: z.uuid(),
56
+ name: z.string(),
57
+ role: z.enum(roles)
58
+ })),
59
+ projects: z.array(z.object({
60
+ id: z.uuid(),
61
+ name: z.string(),
62
+ role: z.enum(roles)
63
+ })),
64
+ providers: z.array(z.string()),
65
+ sessionCount: z.number().int().nonnegative()
66
+ });
67
+ const localPasswordSchema = z.object({ password: z.string().min(12).max(128) }).strict();
68
+ //#endregion
69
+ //#region src/account-administration.ts
70
+ const person = {
71
+ id: user.id,
72
+ name: user.name,
73
+ email: user.email,
74
+ image: user.image,
75
+ role: user.role,
76
+ banned: sql`coalesce(${user.banned}, false)`,
77
+ createdAt: user.createdAt
78
+ };
79
+ const mutationBody = z.object({
80
+ userId: z.string().optional(),
81
+ role: z.union([z.string(), z.array(z.string())]).optional(),
82
+ data: z.record(z.string(), z.unknown()).optional()
83
+ }).passthrough();
84
+ function authPath(request) {
85
+ try {
86
+ return decodeURIComponent(new URL(request.url).pathname).replace(/\/+/g, "/").replace(/\/$/, "");
87
+ } catch {
88
+ return fail(400, "invalid_path", "The authentication path is invalid");
89
+ }
90
+ }
91
+ function createAccountAdministration(database, auth) {
92
+ async function revokeAfterPasswordChange(headers, userId) {
93
+ try {
94
+ await auth.api.revokeUserSessions({
95
+ headers,
96
+ body: { userId }
97
+ });
98
+ } catch {
99
+ fail(503, "session_revocation_failed", "The password changed, but existing sessions could not be revoked. Revoke this user’s sessions before continuing.");
100
+ }
101
+ }
102
+ async function mutate(request, run) {
103
+ const login = await auth.api.getSession({ headers: request.headers });
104
+ if (!login) fail(401, "unauthorized", "Sign in to continue");
105
+ if (!hasAdminRole(login.user.role) || login.user.banned) fail(403, "forbidden", "Instance administrator access is required");
106
+ const lock = await database.pool.connect();
107
+ let locked = false;
108
+ try {
109
+ await lock.query("SELECT pg_advisory_lock(7361083)");
110
+ locked = true;
111
+ const path = authPath(request);
112
+ let passwordUserId;
113
+ if (path.endsWith("/admin/set-user-password")) passwordUserId = mutationBody.parse(await request.clone().json()).userId;
114
+ if ([
115
+ "set-role",
116
+ "update-user",
117
+ "ban-user",
118
+ "remove-user"
119
+ ].some((name) => path.endsWith(`/admin/${name}`))) {
120
+ const body = mutationBody.parse(await request.clone().json());
121
+ if (body.userId) {
122
+ const current = one((await lock.query(`SELECT id, role, coalesce(banned,false) AS banned FROM "user" WHERE id = $1`, [body.userId])).rows);
123
+ const role = body.role ?? body.data?.role ?? current.role;
124
+ const candidate = z.union([z.string(), z.array(z.string())]).nullable().safeParse(role);
125
+ const nextRole = candidate.success ? Array.isArray(candidate.data) ? candidate.data.join(",") : candidate.data : current.role;
126
+ if (body.data && Object.hasOwn(body.data, "banned")) z.boolean().parse(body.data.banned);
127
+ const banned = path.endsWith("/ban-user") || body.data?.banned === true || body.data?.banned === void 0 && current.banned;
128
+ if (hasAdminRole(current.role) && !current.banned && (!hasAdminRole(nextRole) || banned || path.endsWith("/remove-user"))) {
129
+ if (!(await lock.query(`SELECT id FROM "user" WHERE id <> $1 AND NOT coalesce(banned,false) AND 'admin' = ANY(string_to_array(role, ',')) LIMIT 1`, [current.id])).rowCount) fail(409, "last_administrator", "Keep at least one active instance administrator");
130
+ }
131
+ }
132
+ }
133
+ const response = await run();
134
+ if (response.ok && passwordUserId) await revokeAfterPasswordChange(request.headers, passwordUserId);
135
+ return response;
136
+ } finally {
137
+ try {
138
+ if (locked) await lock.query("SELECT pg_advisory_unlock(7361083)");
139
+ } finally {
140
+ lock.release();
141
+ }
142
+ }
143
+ }
144
+ return {
145
+ handleAuth(request) {
146
+ const path = authPath(request);
147
+ const guarded = [
148
+ "set-role",
149
+ "update-user",
150
+ "ban-user",
151
+ "unban-user",
152
+ "remove-user",
153
+ "set-user-password"
154
+ ].some((name) => path === `/api/auth/admin/${name}`);
155
+ return request.method === "POST" && guarded ? mutate(request, () => auth.handler(request)) : auth.handler(request);
156
+ },
157
+ register(app) {
158
+ app.openAPIRegistry.registerPath({
159
+ method: "get",
160
+ path: "/api/v1/admin/users",
161
+ security: [{ sessionCookie: [] }],
162
+ request: { query: accountQuerySchema },
163
+ responses: { 200: {
164
+ description: "Accounts without credentials or session tokens",
165
+ content: { "application/json": { schema: accountsSchema } }
166
+ } }
167
+ });
168
+ app.openAPIRegistry.registerPath({
169
+ method: "get",
170
+ path: "/api/v1/admin/users/{id}",
171
+ security: [{ sessionCookie: [] }],
172
+ request: { params: z.object({ id: z.string() }) },
173
+ responses: { 200: {
174
+ description: "Account access and sign-in summary",
175
+ content: { "application/json": { schema: accountDetailsSchema } }
176
+ } }
177
+ });
178
+ app.openAPIRegistry.registerPath({
179
+ method: "post",
180
+ path: "/api/v1/admin/users/{id}/password",
181
+ security: [{ sessionCookie: [] }],
182
+ request: {
183
+ params: z.object({ id: z.string() }),
184
+ body: {
185
+ required: true,
186
+ content: { "application/json": { schema: localPasswordSchema } }
187
+ }
188
+ },
189
+ responses: { 200: {
190
+ description: "Local password changed and sessions revoked",
191
+ content: { "application/json": { schema: okSchema } }
192
+ } }
193
+ });
194
+ app.get("/api/v1/admin/users", async (c) => {
195
+ const input = accountQuerySchema.parse(c.req.query()), needle = `%${input.q.replace(/[\\%_]/g, "\\$&")}%`;
196
+ const administrator = sql`'admin' = ANY(string_to_array(${user.role}, ','))`;
197
+ const filter = and(input.q ? or(ilike(user.name, needle), ilike(user.email, needle)) : void 0, input.role === "admin" ? administrator : input.role === "user" ? sql`NOT coalesce(${administrator},false)` : void 0, input.status === "all" ? void 0 : input.status === "disabled" ? eq(user.banned, true) : sql`NOT coalesce(${user.banned},false)`);
198
+ const [users, total] = await Promise.all([database.db.select(person).from(user).where(filter).orderBy(asc(user.name), asc(user.id)).limit(input.limit).offset(input.offset), database.db.select({ value: count() }).from(user).where(filter)]);
199
+ return Response.json(json({
200
+ users,
201
+ total: total[0]?.value ?? 0
202
+ }));
203
+ });
204
+ app.get("/api/v1/admin/users/:id", async (c) => {
205
+ const id = c.req.param("id"), row = one(await database.db.select(person).from(user).where(eq(user.id, id)));
206
+ const [spaces, direct, providers, sessions] = await Promise.all([
207
+ database.db.select({
208
+ id: workspaces.id,
209
+ name: workspaces.name,
210
+ role: workspaceMembers.role
211
+ }).from(workspaceMembers).innerJoin(workspaces, eq(workspaces.id, workspaceMembers.workspaceId)).where(eq(workspaceMembers.userId, id)).orderBy(workspaces.name),
212
+ database.db.select({
213
+ id: projects.id,
214
+ name: projects.name,
215
+ role: members.role
216
+ }).from(members).innerJoin(projects, eq(projects.id, members.projectId)).where(eq(members.userId, id)).orderBy(projects.name),
217
+ database.db.select({ provider: account.providerId }).from(account).where(eq(account.userId, id)),
218
+ database.db.select({ value: count() }).from(session).where(and(eq(session.userId, id), gt(session.expiresAt, /* @__PURE__ */ new Date())))
219
+ ]);
220
+ return Response.json(json({
221
+ ...row,
222
+ workspaces: spaces,
223
+ projects: direct,
224
+ providers: [...new Set(providers.map((provider) => provider.provider))],
225
+ sessionCount: sessions[0]?.value ?? 0
226
+ }));
227
+ });
228
+ app.post("/api/v1/admin/users/:id/password", async (c) => mutate(c.req.raw, async () => {
229
+ const input = localPasswordSchema.parse(await c.req.json()), userId = c.req.param("id");
230
+ await auth.api.setUserPassword({
231
+ headers: c.req.raw.headers,
232
+ body: {
233
+ userId,
234
+ newPassword: input.password
235
+ }
236
+ });
237
+ await revokeAfterPasswordChange(c.req.raw.headers, userId);
238
+ return Response.json({ ok: true });
239
+ }));
240
+ }
241
+ };
242
+ }
243
+ //#endregion
244
+ //#region src/instance.ts
245
+ function storageIdentity(settings) {
246
+ return createHash("sha256").update(JSON.stringify([
247
+ settings.endpoint,
248
+ settings.region,
249
+ settings.bucket,
250
+ settings.prefix,
251
+ settings.forcePathStyle
252
+ ])).digest("hex");
253
+ }
254
+ function createInstance(database, config) {
255
+ const key = createHash("sha256").update("kanvil:instance-config:v1:").update(config.BETTER_AUTH_SECRET).digest();
256
+ function seal(value) {
257
+ const iv = randomBytes(12), cipher = createCipheriv("aes-256-gcm", key, iv);
258
+ const bytes = Buffer.concat([cipher.update(JSON.stringify(value)), cipher.final()]);
259
+ return Buffer.concat([
260
+ iv,
261
+ cipher.getAuthTag(),
262
+ bytes
263
+ ]).toString("base64");
264
+ }
265
+ function unseal(value) {
266
+ try {
267
+ const bytes = Buffer.from(value, "base64"), decipher = createDecipheriv("aes-256-gcm", key, bytes.subarray(0, 12));
268
+ decipher.setAuthTag(bytes.subarray(12, 28));
269
+ return JSON.parse(Buffer.concat([decipher.update(bytes.subarray(28)), decipher.final()]).toString());
270
+ } catch {
271
+ return fail(503, "configuration_key_changed", "Saved integration credentials cannot be decrypted. Restore the instance secret from your backup.");
272
+ }
273
+ }
274
+ async function read(db = database.db) {
275
+ await db.insert(instanceSettings).values({ id: 1 }).onConflictDoNothing();
276
+ return one(await db.select().from(instanceSettings).where(eq(instanceSettings.id, 1)));
277
+ }
278
+ async function backend(id, db = database.db) {
279
+ const row = one(await db.select().from(storageBackends).where(eq(storageBackends.id, id)));
280
+ const settings = s3SettingsSchema.parse(unseal(row.config));
281
+ return {
282
+ ...row,
283
+ settings,
284
+ spec: {
285
+ provider: "s3",
286
+ config: settings
287
+ }
288
+ };
289
+ }
290
+ async function resolveS3(settings, db = database.db) {
291
+ settings = s3SettingsSchema.strip().parse(settings);
292
+ const identity = storageIdentity(settings);
293
+ const previous = (await db.select().from(storageBackends).where(eq(storageBackends.identity, identity)))[0];
294
+ const old = previous ? s3SettingsSchema.parse(unseal(previous.config)) : void 0;
295
+ const { secretAccessKey: suppliedSecret, ...publicSettings } = settings, secret = suppliedSecret || (old?.accessKeyId === settings.accessKeyId ? old.secretAccessKey : void 0);
296
+ if (settings.accessKeyId && !secret) fail(400, "missing_secret", "Provide the S3 secret access key");
297
+ return {
298
+ ...publicSettings,
299
+ ...secret ? { secretAccessKey: secret } : {}
300
+ };
301
+ }
302
+ async function resolveSearch(settings, db = database.db) {
303
+ settings = searchSettingsSchema.strip().parse(settings);
304
+ const row = await read(db), old = row.searchConfig ? searchSettingsSchema.parse(unseal(row.searchConfig)) : null, same = old?.url === settings.url && old.index === settings.index;
305
+ const { apiKey: suppliedKey, password: suppliedPassword, auth: requestedAuth, ...publicSettings } = settings, auth = requestedAuth ?? (suppliedKey ? "apiKey" : suppliedPassword && settings.username ? "basic" : same && old ? searchAuthentication(old) : searchAuthentication(settings)), apiKey = suppliedKey || (same ? old?.apiKey : void 0), password = suppliedPassword || (same && old?.username === settings.username ? old?.password : void 0);
306
+ if (auth === "apiKey" && !apiKey) fail(400, "missing_secret", "Provide the search API key");
307
+ if (auth === "basic" && (!settings.username || !password)) fail(400, "missing_secret", "Provide the search username and password");
308
+ return {
309
+ ...publicSettings,
310
+ auth,
311
+ ...auth === "apiKey" && apiKey ? { apiKey } : {},
312
+ ...auth === "basic" && password ? { password } : {}
313
+ };
314
+ }
315
+ async function saveBackend(settings, db = database.db) {
316
+ const complete = await resolveS3(settings, db), identity = storageIdentity(settings);
317
+ return one(await db.insert(storageBackends).values({
318
+ identity,
319
+ config: seal(complete)
320
+ }).onConflictDoUpdate({
321
+ target: storageBackends.identity,
322
+ set: { config: seal(complete) }
323
+ }).returning());
324
+ }
325
+ async function storage() {
326
+ if (config.KANVIL_STORAGE_PROVIDER === "local") return null;
327
+ if (config.KANVIL_STORAGE_PROVIDER === "s3") return saveBackend(s3SettingsSchema.parse({
328
+ endpoint: config.S3_ENDPOINT ?? "",
329
+ region: config.S3_REGION,
330
+ bucket: config.S3_BUCKET,
331
+ prefix: config.S3_PREFIX,
332
+ forcePathStyle: config.S3_FORCE_PATH_STYLE,
333
+ accessKeyId: config.S3_ACCESS_KEY_ID ?? "",
334
+ secretAccessKey: config.S3_SECRET_ACCESS_KEY
335
+ }));
336
+ const row = await read();
337
+ return row.storageBackendId ? backend(row.storageBackendId) : null;
338
+ }
339
+ async function search() {
340
+ const row = await read();
341
+ if (config.KANVIL_SEARCH_PROVIDER === "postgres") return null;
342
+ if (config.KANVIL_SEARCH_PROVIDER === "elasticsearch") {
343
+ const settings = searchSettingsSchema.parse({
344
+ url: config.ELASTICSEARCH_URL,
345
+ index: config.ELASTICSEARCH_INDEX ?? `kanvil-${row.identity}`,
346
+ auth: config.ELASTICSEARCH_AUTH,
347
+ apiKey: config.ELASTICSEARCH_API_KEY,
348
+ username: config.ELASTICSEARCH_USERNAME,
349
+ password: config.ELASTICSEARCH_PASSWORD
350
+ });
351
+ return {
352
+ ...settings,
353
+ auth: searchAuthentication(settings)
354
+ };
355
+ }
356
+ const settings = row.searchConfig ? searchSettingsSchema.parse(unseal(row.searchConfig)) : null;
357
+ return settings ? {
358
+ ...settings,
359
+ auth: searchAuthentication(settings)
360
+ } : null;
361
+ }
362
+ async function broker() {
363
+ if (config.KANVIL_BROKER_PROVIDER === "postgres") return null;
364
+ if (config.KANVIL_BROKER_PROVIDER === "rabbitmq") return brokerSettingsSchema.parse({
365
+ url: config.RABBITMQ_URL,
366
+ exchange: config.RABBITMQ_EXCHANGE
367
+ });
368
+ const row = await read();
369
+ return row.brokerConfig ? brokerSettingsSchema.parse(unseal(row.brokerConfig)) : null;
370
+ }
371
+ async function resolveBroker(settings, db = database.db) {
372
+ settings = brokerSettingsSchema.strip().parse(settings);
373
+ const row = await read(db), old = row.brokerConfig ? brokerSettingsSchema.parse(unseal(row.brokerConfig)) : null;
374
+ const url = settings.url || old?.url;
375
+ if (!url) return fail(400, "missing_secret", "Provide the RabbitMQ connection URL");
376
+ return {
377
+ ...settings,
378
+ url
379
+ };
380
+ }
381
+ async function update(value) {
382
+ const input = instanceUpdateSchema.parse(value);
383
+ return database.db.transaction(async (tx) => {
384
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(7361083)`);
385
+ const current = await read(tx);
386
+ assertVersion(current, input.expectedVersion, "instance settings", "saving");
387
+ let storageId = current.storageBackendId, searchConfig = current.searchConfig, brokerConfig = current.brokerConfig;
388
+ if (!config.KANVIL_STORAGE_PROVIDER) storageId = input.storage.provider === "s3" ? (await saveBackend(input.storage, tx)).id : null;
389
+ if (!config.KANVIL_SEARCH_PROVIDER) {
390
+ if (input.search.provider === "postgres") searchConfig = null;
391
+ else searchConfig = seal(await resolveSearch(input.search, tx));
392
+ }
393
+ if (!config.KANVIL_BROKER_PROVIDER && input.broker) brokerConfig = input.broker.provider === "postgres" ? null : seal(await resolveBroker(input.broker, tx));
394
+ await tx.update(instanceSettings).set({
395
+ name: input.name,
396
+ description: input.description,
397
+ storageBackendId: storageId,
398
+ searchConfig,
399
+ brokerConfig,
400
+ version: current.version + 1
401
+ }).where(eq(instanceSettings.id, 1));
402
+ });
403
+ }
404
+ return {
405
+ read,
406
+ storage,
407
+ search,
408
+ backend,
409
+ saveBackend,
410
+ resolveS3,
411
+ resolveSearch,
412
+ broker,
413
+ resolveBroker,
414
+ update,
415
+ config
416
+ };
417
+ }
418
+ //#endregion
419
+ //#region src/access.ts
420
+ const credentialPermissions = z.object({ kanvil: z.array(z.enum(scopes)) });
421
+ const credentialMetadata = z.object({ projectIds: z.array(z.uuid()).max(1e3) });
422
+ function createWriteLimiter(max, windowMs = 6e4) {
423
+ const recent = /* @__PURE__ */ new Map();
424
+ return { consume(key, now = Date.now()) {
425
+ const hits = (recent.get(key) ?? []).filter((at) => at > now - windowMs);
426
+ if (hits.length >= max) {
427
+ const retryAfterSeconds = Math.max(1, Math.ceil(((hits[0] ?? now) + windowMs - now) / 1e3));
428
+ fail(429, "rate_limited", "Too many changes in a short time; wait and retry", { retryAfterSeconds });
429
+ }
430
+ hits.push(now);
431
+ recent.set(key, hits);
432
+ if (recent.size > 1e4) {
433
+ for (const [id, stamps] of recent) if (!stamps.some((at) => at > now - windowMs)) recent.delete(id);
434
+ }
435
+ } };
436
+ }
437
+ function requireSession(actor) {
438
+ if (actor.machine) fail(403, "session_required", "Use a signed-in account for this action");
439
+ return actor;
440
+ }
441
+ function requireInstanceAdmin(actor) {
442
+ requireSession(actor);
443
+ if (!actor.admin) fail(403, "forbidden", "Instance administrator access is required");
444
+ return actor;
445
+ }
446
+ function storedJson(value) {
447
+ if (typeof value !== "string") return value;
448
+ try {
449
+ return JSON.parse(value);
450
+ } catch {
451
+ return null;
452
+ }
453
+ }
454
+ function createAccess(auth, domain, config, database) {
455
+ async function credentialActor(key) {
456
+ const permissions = credentialPermissions.safeParse(storedJson(key.permissions));
457
+ const metadata = credentialMetadata.safeParse(storedJson(key.metadata));
458
+ if (!permissions.success || !metadata.success) fail(403, "invalid_credential_scope", "This credential has no valid project permissions");
459
+ return domain.actor(key.referenceId, {
460
+ ownerId: key.id,
461
+ scopes: permissions.data.kanvil,
462
+ projectIds: metadata.data.projectIds
463
+ });
464
+ }
465
+ async function authenticate(request) {
466
+ const token = request.headers.get("authorization")?.replace(/^Bearer\s+/i, "");
467
+ if (token) {
468
+ if (!token.startsWith("kv_")) fail(401, "unauthorized", "Use a Kanvil API credential");
469
+ const result = await auth.api.verifyApiKey({ body: { key: token } });
470
+ if (!result.valid || !result.key) fail(401, "unauthorized", "The API credential is invalid or expired");
471
+ return credentialActor(result.key);
472
+ }
473
+ const session = await auth.api.getSession({ headers: request.headers });
474
+ if (!session) fail(401, "unauthorized", "Sign in to continue");
475
+ return domain.actor(session.user.id);
476
+ }
477
+ async function revalidate(request, identity) {
478
+ if (!identity.machine) return authenticate(request);
479
+ const key = (await database.db.select().from(apikey).where(eq(apikey.id, identity.ownerId)))[0];
480
+ if (!key?.enabled || key.referenceId !== identity.id || key.expiresAt && key.expiresAt <= /* @__PURE__ */ new Date()) fail(401, "unauthorized", "The API credential is invalid or expired");
481
+ return credentialActor(key);
482
+ }
483
+ function checkOrigin(request) {
484
+ if ([
485
+ "GET",
486
+ "HEAD",
487
+ "OPTIONS"
488
+ ].includes(request.method)) return;
489
+ const origin = request.headers.get("origin");
490
+ if (origin && !config.trustedOrigins.includes(origin)) fail(403, "invalid_origin", "This origin is not allowed");
491
+ if (request.headers.get("sec-fetch-site") === "cross-site" && !origin) fail(403, "invalid_origin", "Cross-site writes are not allowed");
492
+ }
493
+ return {
494
+ authenticate,
495
+ revalidate,
496
+ checkOrigin
497
+ };
498
+ }
499
+ //#endregion
500
+ //#region src/realtime.ts
501
+ async function createRealtime(config) {
502
+ const emitter = new EventEmitter();
503
+ emitter.setMaxListeners(0);
504
+ let client, closed = false, retry;
505
+ async function connect() {
506
+ if (closed) return;
507
+ const connection = new pg.Client({ connectionString: config.DATABASE_URL });
508
+ client = connection;
509
+ const disconnected = () => {
510
+ if (client !== connection || closed) return;
511
+ client = void 0;
512
+ connection.end().catch(() => {});
513
+ clearTimeout(retry);
514
+ retry = setTimeout(() => {
515
+ connect();
516
+ }, 1e3);
517
+ };
518
+ connection.on("error", disconnected);
519
+ connection.on("end", disconnected);
520
+ connection.on("notification", () => emitter.emit("change"));
521
+ try {
522
+ await connection.connect();
523
+ await connection.query("LISTEN kanvil_events");
524
+ emitter.emit("change");
525
+ } catch {
526
+ disconnected();
527
+ }
528
+ }
529
+ await connect();
530
+ return {
531
+ subscribe(listener) {
532
+ emitter.on("change", listener);
533
+ return () => {
534
+ emitter.off("change", listener);
535
+ };
536
+ },
537
+ async close() {
538
+ closed = true;
539
+ clearTimeout(retry);
540
+ emitter.emit("close");
541
+ emitter.removeAllListeners();
542
+ await client?.end();
543
+ },
544
+ onClose(listener) {
545
+ emitter.on("close", listener);
546
+ return () => {
547
+ emitter.off("close", listener);
548
+ };
549
+ }
550
+ };
551
+ }
552
+ //#endregion
553
+ //#region src/adapters/rabbitmq.ts
554
+ async function bounded(operation, milliseconds = 5e3) {
555
+ let timer;
556
+ try {
557
+ return await Promise.race([operation, new Promise((_resolve, reject) => {
558
+ timer = setTimeout(() => reject(/* @__PURE__ */ new Error("Broker operation timed out")), milliseconds);
559
+ })]);
560
+ } finally {
561
+ clearTimeout(timer);
562
+ }
563
+ }
564
+ const createRabbitMqBus = async (configuration, receive, changed) => {
565
+ const settings = brokerSettingsSchema.parse(configuration.config);
566
+ if (!settings.url) throw new Error("RabbitMQ connection URL is required");
567
+ const url = new URL(settings.url);
568
+ if (!url.searchParams.has("heartbeat")) url.searchParams.set("heartbeat", "10");
569
+ let channel, available = false;
570
+ const connection = await connect(url.toString(), {
571
+ timeout: 5e3,
572
+ keepAlive: true,
573
+ clientProperties: { connection_name: "Kanvil event delivery" },
574
+ recovery: {
575
+ waitForConnect: false,
576
+ initialDelay: 1e3,
577
+ maxDelay: 1e4,
578
+ async setup(model) {
579
+ available = false;
580
+ const next = await model.createConfirmChannel();
581
+ next.on("error", () => {});
582
+ next.on("close", () => {
583
+ if (channel === next) {
584
+ available = false;
585
+ changed();
586
+ }
587
+ });
588
+ await bounded((async () => {
589
+ await next.assertExchange(settings.exchange, "fanout", { durable: true });
590
+ const queue = await next.assertQueue("", {
591
+ exclusive: true,
592
+ autoDelete: true,
593
+ durable: false,
594
+ arguments: {
595
+ "x-max-length": 1e3,
596
+ "x-overflow": "drop-head"
597
+ }
598
+ });
599
+ await next.bindQueue(queue.queue, settings.exchange, "");
600
+ await next.prefetch(20);
601
+ await next.consume(queue.queue, (message) => {
602
+ if (!message) {
603
+ available = false;
604
+ changed();
605
+ return;
606
+ }
607
+ try {
608
+ if (message.content.length <= 2048) receive(message.content.toString());
609
+ } finally {
610
+ next.ack(message);
611
+ }
612
+ }, { noAck: false });
613
+ })());
614
+ channel = next;
615
+ available = true;
616
+ changed();
617
+ }
618
+ }
619
+ });
620
+ const unavailable = () => {
621
+ available = false;
622
+ changed();
623
+ };
624
+ connection.on("error", unavailable);
625
+ connection.on("disconnect", unavailable);
626
+ connection.on("connect-failed", unavailable);
627
+ connection.on("blocked", unavailable);
628
+ connection.on("unblocked", () => {
629
+ available = !!channel;
630
+ changed();
631
+ });
632
+ return {
633
+ ready: () => available,
634
+ async publish(message) {
635
+ const current = channel;
636
+ if (!current || !available) throw new Error("RabbitMQ is unavailable");
637
+ await bounded(new Promise((resolve, reject) => {
638
+ current.publish(settings.exchange, "", Buffer.from(message), {
639
+ persistent: true,
640
+ contentType: "application/json"
641
+ }, (error) => error ? reject(/* @__PURE__ */ new Error("RabbitMQ did not confirm publication")) : resolve());
642
+ }));
643
+ },
644
+ async close() {
645
+ available = false;
646
+ await bounded(connection.close());
647
+ }
648
+ };
649
+ };
650
+ //#endregion
651
+ //#region src/adapters/wakeup-buses.ts
652
+ const providers = { rabbitmq: createRabbitMqBus };
653
+ const createWakeupBus = (configuration, receive, changed) => {
654
+ const factory = providers[configuration.provider];
655
+ if (!factory) throw new Error("Unsupported event delivery provider");
656
+ return factory(configuration, receive, changed);
657
+ };
658
+ //#endregion
659
+ //#region src/event-delivery.ts
660
+ const messageSchema = z.object({
661
+ instanceId: z.uuid(),
662
+ cursor: z.string().regex(/^\d+$/)
663
+ });
664
+ function brokerEndpoint(settings) {
665
+ if (!settings.url) return "";
666
+ const url = new URL(settings.url);
667
+ return `${url.protocol}//${url.host}${url.pathname}`;
668
+ }
669
+ async function createEventDelivery(config, instance, database, factory = createWakeupBus) {
670
+ const postgres = await createRealtime(config), emitter = new EventEmitter();
671
+ emitter.setMaxListeners(0);
672
+ const identity = (await instance.read()).identity;
673
+ let bus, fingerprint = "", destination = "", closed = false, pending, dirty = false, error = null;
674
+ const wake = () => emitter.emit("change");
675
+ function receive(message) {
676
+ try {
677
+ const parsed = messageSchema.safeParse(JSON.parse(message));
678
+ if (parsed.success && parsed.data.instanceId === identity) wake();
679
+ } catch {}
680
+ }
681
+ async function process() {
682
+ const settings = await instance.broker(), next = JSON.stringify(settings);
683
+ if (next !== fingerprint) {
684
+ const old = bus;
685
+ bus = void 0;
686
+ fingerprint = next;
687
+ await old?.close().catch(() => {});
688
+ if (settings) {
689
+ destination = createHash("sha256").update(JSON.stringify([
690
+ identity,
691
+ brokerEndpoint(settings),
692
+ settings.exchange
693
+ ])).digest("hex");
694
+ bus = await factory({
695
+ provider: "rabbitmq",
696
+ config: settings
697
+ }, receive, () => {
698
+ if (!closed) {
699
+ wake();
700
+ trigger();
701
+ }
702
+ });
703
+ }
704
+ error = null;
705
+ wake();
706
+ }
707
+ if (!bus) return;
708
+ if (!bus.ready()) {
709
+ error = "RabbitMQ is unavailable. PostgreSQL is delivering event wakeups.";
710
+ return;
711
+ }
712
+ await database.db.transaction(async (tx) => {
713
+ if (!(await tx.execute(sql`SELECT pg_try_advisory_xact_lock(7361085) AS locked`)).rows[0]?.locked) return;
714
+ const clock = (await tx.select().from(eventClock).where(eq(eventClock.id, 1)))[0]?.value ?? 0n;
715
+ if (clock <= ((await tx.select().from(deliveryCheckpoints).where(eq(deliveryCheckpoints.destination, destination)))[0]?.cursor ?? 0n)) return;
716
+ await bus?.publish(JSON.stringify({
717
+ instanceId: identity,
718
+ cursor: clock.toString()
719
+ }));
720
+ await tx.insert(deliveryCheckpoints).values({
721
+ destination,
722
+ cursor: clock
723
+ }).onConflictDoUpdate({
724
+ target: deliveryCheckpoints.destination,
725
+ set: { cursor: clock }
726
+ });
727
+ });
728
+ error = null;
729
+ }
730
+ function trigger() {
731
+ if (closed) return;
732
+ dirty = true;
733
+ pending ??= (async () => {
734
+ while (dirty) {
735
+ dirty = false;
736
+ if (closed) break;
737
+ try {
738
+ await process();
739
+ } catch {
740
+ error = "RabbitMQ is unavailable. PostgreSQL is delivering event wakeups.";
741
+ if (!bus) fingerprint = "";
742
+ wake();
743
+ }
744
+ }
745
+ })().finally(() => {
746
+ pending = void 0;
747
+ });
748
+ }
749
+ const unsubscribe = postgres.subscribe(() => {
750
+ if (!bus?.ready() || error) wake();
751
+ trigger();
752
+ });
753
+ const timer = setInterval(() => {
754
+ if (error) wake();
755
+ trigger();
756
+ }, 1e3);
757
+ timer.unref();
758
+ trigger();
759
+ return {
760
+ subscribe(listener) {
761
+ emitter.on("change", listener);
762
+ return () => {
763
+ emitter.off("change", listener);
764
+ };
765
+ },
766
+ onClose(listener) {
767
+ emitter.on("close", listener);
768
+ return () => {
769
+ emitter.off("close", listener);
770
+ };
771
+ },
772
+ status() {
773
+ return {
774
+ status: !bus && fingerprint === "null" ? "builtin" : error ? "unavailable" : bus?.ready() ? "ready" : "connecting",
775
+ error
776
+ };
777
+ },
778
+ async test(settings) {
779
+ const token = crypto.randomUUID();
780
+ let received;
781
+ const readback = new Promise((resolve) => {
782
+ received = resolve;
783
+ });
784
+ const probe = await factory({
785
+ provider: "rabbitmq",
786
+ config: settings
787
+ }, (value) => {
788
+ if (value === token) received?.();
789
+ }, () => {});
790
+ try {
791
+ const deadline = Date.now() + 5e3;
792
+ while (!probe.ready()) {
793
+ if (Date.now() >= deadline) throw new Error("Broker connection timed out");
794
+ await new Promise((resolve) => setTimeout(resolve, 25));
795
+ }
796
+ await probe.publish(token);
797
+ await bounded(readback);
798
+ } catch {
799
+ fail(503, "broker_unavailable", "RabbitMQ connection could not be verified. Check its URL, exchange, and permissions.");
800
+ } finally {
801
+ await probe.close().catch(() => {});
802
+ }
803
+ },
804
+ async close() {
805
+ closed = true;
806
+ clearInterval(timer);
807
+ unsubscribe();
808
+ await pending;
809
+ await bus?.close().catch(() => {});
810
+ emitter.emit("close");
811
+ emitter.removeAllListeners();
812
+ await postgres.close();
813
+ }
814
+ };
815
+ }
816
+ //#endregion
817
+ //#region src/instance-routes.ts
818
+ function registerInstanceRoutes(app, database, instance, blobs, search, delivery) {
819
+ app.openAPIRegistry.registerComponent("securitySchemes", "sessionCookie", {
820
+ type: "apiKey",
821
+ in: "cookie",
822
+ name: "better-auth.session_token",
823
+ description: "Signed-in local or OIDC session. Instance administration additionally requires the admin role."
824
+ });
825
+ const endpoints = [
826
+ {
827
+ method: "get",
828
+ path: "/admin/instance",
829
+ output: adminInstanceSchema
830
+ },
831
+ {
832
+ method: "patch",
833
+ path: "/admin/instance",
834
+ input: instanceUpdateSchema,
835
+ output: okSchema
836
+ },
837
+ {
838
+ method: "post",
839
+ path: "/admin/storage/test",
840
+ input: s3SettingsSchema,
841
+ output: okSchema
842
+ },
843
+ {
844
+ method: "post",
845
+ path: "/admin/search/test",
846
+ input: searchSettingsSchema,
847
+ output: okSchema
848
+ },
849
+ {
850
+ method: "post",
851
+ path: "/admin/broker/test",
852
+ input: brokerSettingsSchema,
853
+ output: okSchema
854
+ },
855
+ {
856
+ method: "post",
857
+ path: "/admin/search/reindex",
858
+ output: okSchema,
859
+ status: 202
860
+ },
861
+ {
862
+ method: "post",
863
+ path: "/admin/storage/cleanup",
864
+ output: z.object({ removed: z.number() })
865
+ },
866
+ {
867
+ method: "post",
868
+ path: "/profile/photo",
869
+ input: photoInputSchema,
870
+ output: photoResultSchema
871
+ },
872
+ {
873
+ method: "delete",
874
+ path: "/profile/photo",
875
+ output: photoResultSchema
876
+ }
877
+ ];
878
+ for (const endpoint of endpoints) app.openAPIRegistry.registerPath({
879
+ method: endpoint.method,
880
+ path: `/api/v1${endpoint.path}`,
881
+ security: [{ sessionCookie: [] }],
882
+ ..."input" in endpoint ? { request: { body: {
883
+ content: { "application/json": { schema: endpoint.input } },
884
+ required: true
885
+ } } } : {},
886
+ responses: {
887
+ ["status" in endpoint ? endpoint.status : 200]: {
888
+ description: "Success",
889
+ content: { "application/json": { schema: endpoint.output } }
890
+ },
891
+ 401: { description: "Session required" },
892
+ 403: { description: "Administrator access required" },
893
+ 409: { description: "Settings version conflict" },
894
+ 503: { description: "Integration unavailable" }
895
+ }
896
+ });
897
+ app.openAPIRegistry.registerPath({
898
+ method: "get",
899
+ path: "/api/v1/users/{id}/photo",
900
+ security: [{ sessionCookie: [] }],
901
+ request: { params: z.object({ id: z.string() }) },
902
+ responses: {
903
+ 200: {
904
+ description: "Private profile image",
905
+ content: {
906
+ "image/png": { schema: z.string() },
907
+ "image/jpeg": { schema: z.string() },
908
+ "image/webp": { schema: z.string() }
909
+ }
910
+ },
911
+ 401: { description: "Session required" },
912
+ 404: { description: "No profile photo" }
913
+ }
914
+ });
915
+ app.get("/api/v1/admin/instance", async () => {
916
+ const row = await instance.read(), active = await instance.storage(), backend = active ? await instance.backend(active.id) : null, broker = await instance.broker(), external = await instance.search(), status = await search.status();
917
+ const locations = await database.db.select({
918
+ id: storageBackends.id,
919
+ config: storageBackends.config,
920
+ objects: count(blobObjects.id)
921
+ }).from(storageBackends).leftJoin(blobObjects, eq(blobObjects.backendId, storageBackends.id)).groupBy(storageBackends.id);
922
+ return Response.json({
923
+ id: row.identity,
924
+ version: row.version,
925
+ name: row.name,
926
+ description: row.description,
927
+ storage: {
928
+ provider: backend ? "s3" : "local",
929
+ source: instance.config.KANVIL_STORAGE_PROVIDER ? "deployment" : "admin",
930
+ settings: backend ? {
931
+ ...backend.settings,
932
+ secretAccessKey: void 0
933
+ } : null,
934
+ secretConfigured: !!backend?.settings.secretAccessKey,
935
+ ...await blobs.status()
936
+ },
937
+ search: {
938
+ provider: external ? "elasticsearch" : "postgres",
939
+ source: instance.config.KANVIL_SEARCH_PROVIDER ? "deployment" : "admin",
940
+ settings: external ? {
941
+ ...external,
942
+ apiKey: void 0,
943
+ password: void 0
944
+ } : null,
945
+ secretConfigured: !!(external?.apiKey || external?.password),
946
+ ...status
947
+ },
948
+ broker: {
949
+ provider: broker ? "rabbitmq" : "postgres",
950
+ source: instance.config.KANVIL_BROKER_PROVIDER ? "deployment" : "admin",
951
+ settings: broker ? {
952
+ endpoint: brokerEndpoint(broker),
953
+ exchange: broker.exchange
954
+ } : null,
955
+ secretConfigured: !!broker?.url,
956
+ ...delivery.status()
957
+ },
958
+ deployment: {
959
+ url: instance.config.KANVIL_URL,
960
+ version: "0.1.0",
961
+ oidc: instance.config.OIDC_DISCOVERY_URL ? {
962
+ name: instance.config.OIDC_NAME,
963
+ discoveryUrl: instance.config.OIDC_DISCOVERY_URL,
964
+ clientId: instance.config.OIDC_CLIENT_ID
965
+ } : null,
966
+ push: instance.config.KANVIL_PUSH_ENABLED
967
+ },
968
+ locations: await Promise.all(locations.map(async (location) => {
969
+ const saved = await instance.backend(location.id);
970
+ return {
971
+ id: location.id,
972
+ bucket: saved.settings.bucket,
973
+ endpoint: saved.settings.endpoint,
974
+ prefix: saved.settings.prefix,
975
+ objects: location.objects
976
+ };
977
+ }))
978
+ });
979
+ });
980
+ app.patch("/api/v1/admin/instance", async (c) => {
981
+ const input = instanceUpdateSchema.parse(await c.req.json());
982
+ assertVersion(await instance.read(), input.expectedVersion, "instance settings", "saving");
983
+ if (!instance.config.KANVIL_STORAGE_PROVIDER && input.storage.provider === "s3") {
984
+ const candidate = await instance.resolveS3(input.storage), active = await instance.storage();
985
+ if (!isDeepStrictEqual(candidate, active ? (await instance.backend(active.id)).settings : null)) await blobs.test({
986
+ provider: "s3",
987
+ config: candidate
988
+ });
989
+ }
990
+ if (!instance.config.KANVIL_SEARCH_PROVIDER && input.search.provider === "elasticsearch") {
991
+ const candidate = await instance.resolveSearch(input.search);
992
+ if (!isDeepStrictEqual(candidate, await instance.search())) await search.test(candidate);
993
+ }
994
+ if (!instance.config.KANVIL_BROKER_PROVIDER && input.broker?.provider === "rabbitmq") {
995
+ const candidate = await instance.resolveBroker(input.broker);
996
+ if (!isDeepStrictEqual(candidate, await instance.broker())) await delivery.test(candidate);
997
+ }
998
+ await instance.update(input);
999
+ return Response.json({ ok: true });
1000
+ });
1001
+ app.post("/api/v1/admin/storage/test", async (c) => {
1002
+ const active = instance.config.KANVIL_STORAGE_PROVIDER ? await instance.storage() : null;
1003
+ await blobs.test({
1004
+ provider: "s3",
1005
+ config: active ? (await instance.backend(active.id)).settings : await instance.resolveS3(s3SettingsSchema.parse(await c.req.json()))
1006
+ });
1007
+ return Response.json({ ok: true });
1008
+ });
1009
+ app.post("/api/v1/admin/search/test", async (c) => {
1010
+ const active = instance.config.KANVIL_SEARCH_PROVIDER ? await instance.search() : null;
1011
+ await search.test(active ?? await instance.resolveSearch(searchSettingsSchema.parse(await c.req.json())));
1012
+ return Response.json({ ok: true });
1013
+ });
1014
+ app.post("/api/v1/admin/broker/test", async (c) => {
1015
+ const active = instance.config.KANVIL_BROKER_PROVIDER ? await instance.broker() : null;
1016
+ await delivery.test(active ?? await instance.resolveBroker(brokerSettingsSchema.parse(await c.req.json())));
1017
+ return Response.json({ ok: true });
1018
+ });
1019
+ app.post("/api/v1/admin/search/reindex", async () => {
1020
+ await search.rebuild();
1021
+ return Response.json({ ok: true }, { status: 202 });
1022
+ });
1023
+ app.post("/api/v1/admin/storage/cleanup", async () => Response.json({ removed: await blobs.collect() }));
1024
+ app.post("/api/v1/profile/photo", async (c) => {
1025
+ const actor = requireSession(c.get("actor")), input = photoInputSchema.parse(await c.req.json()), bytes = decodeImage(input);
1026
+ const id = crypto.randomUUID(), image = `/api/v1/users/${actor.id}/photo?v=${id}`;
1027
+ await database.db.transaction(async (tx) => {
1028
+ await blobs.put(tx, id, bytes, input.contentType);
1029
+ await tx.insert(profilePhotos).values({
1030
+ userId: actor.id,
1031
+ objectId: id
1032
+ }).onConflictDoUpdate({
1033
+ target: profilePhotos.userId,
1034
+ set: { objectId: id }
1035
+ });
1036
+ await tx.update(user).set({
1037
+ image,
1038
+ updatedAt: /* @__PURE__ */ new Date()
1039
+ }).where(eq(user.id, actor.id));
1040
+ });
1041
+ blobs.sync().catch(() => {});
1042
+ return Response.json({ image });
1043
+ });
1044
+ app.delete("/api/v1/profile/photo", async (c) => {
1045
+ const actor = requireSession(c.get("actor"));
1046
+ await database.db.transaction(async (tx) => {
1047
+ await tx.delete(profilePhotos).where(eq(profilePhotos.userId, actor.id));
1048
+ await tx.update(user).set({
1049
+ image: null,
1050
+ updatedAt: /* @__PURE__ */ new Date()
1051
+ }).where(eq(user.id, actor.id));
1052
+ });
1053
+ return Response.json({ image: null });
1054
+ });
1055
+ app.get("/api/v1/users/:id/photo", async (c) => {
1056
+ requireSession(c.get("actor"));
1057
+ const photo = one(await database.db.select().from(profilePhotos).where(eq(profilePhotos.userId, c.req.param("id"))));
1058
+ const blob = await blobs.get(photo.objectId);
1059
+ return imageResponse(blob);
1060
+ });
1061
+ }
1062
+ //#endregion
1063
+ //#region src/push.ts
1064
+ const delay = (attempt) => Math.min(6e4 * 2 ** attempt, 36e5);
1065
+ async function deliverPushBatch(database, config, transport) {
1066
+ const lock = await database.pool.connect();
1067
+ let ownsLock = false;
1068
+ try {
1069
+ if (!(await lock.query("SELECT pg_try_advisory_lock(7361082) AS acquired")).rows[0]?.acquired) return;
1070
+ ownsLock = true;
1071
+ const jobs = await database.db.select({ id: pushDeliveries.id }).from(pushDeliveries).where(and(isNull(pushDeliveries.finishedAt), lte(pushDeliveries.availableAt, /* @__PURE__ */ new Date()))).orderBy(asc(pushDeliveries.availableAt)).limit(100);
1072
+ for (const pending of jobs) {
1073
+ const row = (await database.db.select({
1074
+ job: pushDeliveries,
1075
+ device: pushDevices,
1076
+ notification: notifications,
1077
+ event: events,
1078
+ preference: notificationPreferences,
1079
+ account: user,
1080
+ session,
1081
+ projectRole: projectRoleSQL(user.id, notifications.projectId),
1082
+ subscription: subscriptions,
1083
+ ticketId: tickets.id
1084
+ }).from(pushDeliveries).innerJoin(pushDevices, eq(pushDevices.id, pushDeliveries.deviceId)).innerJoin(notifications, eq(notifications.id, pushDeliveries.notificationId)).innerJoin(events, eq(events.id, notifications.eventId)).leftJoin(tickets, eq(tickets.id, events.ticketId)).innerJoin(user, eq(user.id, pushDevices.userId)).innerJoin(session, eq(session.id, pushDevices.sessionId)).leftJoin(notificationPreferences, eq(notificationPreferences.userId, user.id)).leftJoin(subscriptions, and(eq(subscriptions.ticketId, events.ticketId), eq(subscriptions.userId, user.id))).where(and(eq(pushDeliveries.id, pending.id), isNull(pushDeliveries.finishedAt))))[0];
1085
+ if (!row) continue;
1086
+ const { job, device, event } = row;
1087
+ const finish = async (error) => database.db.update(pushDeliveries).set({
1088
+ finishedAt: /* @__PURE__ */ new Date(),
1089
+ error
1090
+ }).where(eq(pushDeliveries.id, job.id));
1091
+ if (device.userId !== row.notification.userId || row.account.banned || row.session.expiresAt <= /* @__PURE__ */ new Date() || !row.preference?.push || !row.subscription && row.ticketId !== null || !row.projectRole && !hasAdminRole(row.account.role)) {
1092
+ await finish("access_revoked");
1093
+ continue;
1094
+ }
1095
+ if (row.notification.createdAt.getTime() < Date.now() - 864e5) {
1096
+ await finish("expired");
1097
+ continue;
1098
+ }
1099
+ try {
1100
+ let result;
1101
+ if (job.receiptId) result = (await transport.getPushNotificationReceiptsAsync([job.receiptId]))[job.receiptId];
1102
+ else result = (await transport.sendPushNotificationsAsync([{
1103
+ to: device.token,
1104
+ title: "Kanvil",
1105
+ body: "A subscribed ticket has an update.",
1106
+ sound: "default",
1107
+ channelId: "tickets",
1108
+ ttl: 3600,
1109
+ data: {
1110
+ server: config.KANVIL_URL,
1111
+ projectId: event.projectId,
1112
+ ticketId: event.ticketId,
1113
+ notificationId: row.notification.id
1114
+ }
1115
+ }]))[0];
1116
+ if (result?.status === "ok") {
1117
+ if (!job.receiptId && "id" in result) await database.db.update(pushDeliveries).set({
1118
+ receiptId: result.id,
1119
+ availableAt: new Date(Date.now() + 9e5),
1120
+ attempts: 0,
1121
+ error: null
1122
+ }).where(eq(pushDeliveries.id, job.id));
1123
+ else await finish(null);
1124
+ } else if (result?.status === "error" && result.details?.error !== "MessageRateExceeded") {
1125
+ if (result.details?.error === "DeviceNotRegistered") await database.db.delete(pushDevices).where(eq(pushDevices.id, device.id));
1126
+ else await finish(result.details?.error ?? "delivery_rejected");
1127
+ } else await retry(result ? "rate_limited" : "receipt_pending");
1128
+ } catch {
1129
+ await retry("transport_error");
1130
+ }
1131
+ async function retry(error) {
1132
+ await database.db.update(pushDeliveries).set({
1133
+ attempts: job.attempts + 1,
1134
+ error,
1135
+ availableAt: new Date(Date.now() + (job.receiptId ? 9e5 : delay(job.attempts))),
1136
+ finishedAt: job.attempts >= 4 && error !== "receipt_pending" ? /* @__PURE__ */ new Date() : null
1137
+ }).where(eq(pushDeliveries.id, job.id));
1138
+ }
1139
+ }
1140
+ } finally {
1141
+ try {
1142
+ if (ownsLock) await lock.query("SELECT pg_advisory_unlock(7361082)");
1143
+ } finally {
1144
+ lock.release();
1145
+ }
1146
+ }
1147
+ }
1148
+ function createPushDelivery(database, config) {
1149
+ let running;
1150
+ const transport = new Expo(config.EXPO_ACCESS_TOKEN ? { accessToken: config.EXPO_ACCESS_TOKEN } : {});
1151
+ const tick = () => {
1152
+ if (!running) running = deliverPushBatch(database, config, transport).catch(() => {
1153
+ console.error("Push delivery failed; queued notifications will be retried.");
1154
+ }).finally(() => {
1155
+ running = void 0;
1156
+ });
1157
+ };
1158
+ const timer = config.KANVIL_PUSH_ENABLED ? setInterval(tick, 3e4) : void 0;
1159
+ timer?.unref();
1160
+ return { async close() {
1161
+ clearInterval(timer);
1162
+ await running;
1163
+ } };
1164
+ }
1165
+ //#endregion
1166
+ //#region src/runtime-adapters.ts
1167
+ const defaultAdapters = {
1168
+ auth: createAuth,
1169
+ realtime: createEventDelivery,
1170
+ push: createPushDelivery,
1171
+ objectStore: createObjectStore,
1172
+ ticketIndex: createTicketIndex,
1173
+ jobs: createPollingJob
1174
+ };
1175
+ //#endregion
1176
+ //#region src/mcp.ts
1177
+ function createMcp(auth, domain, access, config, limitWrite) {
1178
+ const serve = (request, identity, limited = false) => createMcpHandler(() => {
1179
+ const server = new McpServer({
1180
+ name: "Kanvil",
1181
+ version: "0.1.0"
1182
+ });
1183
+ for (const name of Object.keys(operations).filter(isOperation)) {
1184
+ const spec = operations[name];
1185
+ const inputSchema = spec.input.shape;
1186
+ server.registerTool(name, {
1187
+ description: spec.description,
1188
+ inputSchema,
1189
+ outputSchema: { result: spec.output },
1190
+ annotations: {
1191
+ readOnlyHint: spec.method === "GET",
1192
+ destructiveHint: /^(delete|remove|recover)_/.test(name),
1193
+ idempotentHint: spec.method === "GET",
1194
+ openWorldHint: false
1195
+ }
1196
+ }, async (input) => {
1197
+ try {
1198
+ if (limited && spec.method !== "GET") limitWrite(identity.ownerId);
1199
+ const result = await domain.execute(name, input, identity);
1200
+ return {
1201
+ content: [{
1202
+ type: "text",
1203
+ text: `${name} completed.`
1204
+ }],
1205
+ structuredContent: { result }
1206
+ };
1207
+ } catch (error) {
1208
+ if (!(error instanceof ApiError) && !(error instanceof z.ZodError)) throw error;
1209
+ return {
1210
+ isError: true,
1211
+ content: [{
1212
+ type: "text",
1213
+ text: error instanceof ApiError ? `${error.code}: ${error.message}` : "validation_error: Invalid tool arguments"
1214
+ }]
1215
+ };
1216
+ }
1217
+ });
1218
+ }
1219
+ return server;
1220
+ }, { legacy: "stateless" }).fetch(request);
1221
+ const oauth = requireMcpAuth(auth, async (request, claims) => {
1222
+ if (typeof claims.sub !== "string") throw new ApiError(401, "unauthorized", "A user subject is required");
1223
+ const granted = typeof claims.scope === "string" ? claims.scope.split(" ") : [];
1224
+ const permissions = scopes.filter((scope) => granted.includes(`kanvil:${scope}`));
1225
+ const clientId = typeof claims.client_id === "string" ? claims.client_id : "oauth";
1226
+ return serve(request, await domain.actor(claims.sub, {
1227
+ ownerId: `${claims.sub}:${clientId}`,
1228
+ scopes: permissions
1229
+ }), true);
1230
+ }, {
1231
+ resource: `${config.KANVIL_URL}/mcp`,
1232
+ challengeScopes: ["kanvil:read"]
1233
+ });
1234
+ return async (request) => {
1235
+ if (request.headers.get("authorization")?.match(/^Bearer\s+kv_/i)) return serve(request, await access.authenticate(request));
1236
+ return oauth(request);
1237
+ };
1238
+ }
1239
+ //#endregion
1240
+ //#region src/app.ts
1241
+ const setupInput = z$1.object({
1242
+ token: z$1.string().max(200),
1243
+ email: z$1.email(),
1244
+ name: z$1.string().trim().min(1).max(120),
1245
+ password: z$1.string().min(12).max(128)
1246
+ }).strict();
1247
+ const keyInput = z$1.object({
1248
+ name: z$1.string().trim().min(1).max(100),
1249
+ projectIds: z$1.array(z$1.uuid()).min(1).max(1e3),
1250
+ scopes: z$1.array(z$1.enum(scopes)).min(1),
1251
+ expiresIn: z$1.number().int().min(3600).max(31536e3).default(7776e3)
1252
+ }).strict();
1253
+ const digest = (value) => createHash("sha256").update(value).digest();
1254
+ async function createApplication(database, config, overrides = {}) {
1255
+ const adapters = {
1256
+ ...defaultAdapters,
1257
+ ...overrides
1258
+ };
1259
+ const integrationDatabase = createDatabase(config.DATABASE_URL);
1260
+ const instance = createInstance(integrationDatabase, config);
1261
+ await instance.read();
1262
+ const blobs = createBlobStorage(integrationDatabase, instance, adapters.objectStore, adapters.jobs), search = createTicketSearch(integrationDatabase, instance, adapters.ticketIndex, adapters.jobs);
1263
+ const auth = adapters.auth(database, config), domain = createDomain(database, {
1264
+ blobs,
1265
+ search
1266
+ }), access = createAccess(auth, domain, config, database), realtime = await adapters.realtime(config, instance, integrationDatabase);
1267
+ const unsubscribeJobs = realtime.subscribe(() => {
1268
+ blobs.sync().catch(() => {});
1269
+ search.sync().catch(() => {});
1270
+ });
1271
+ const push = adapters.push(database, config);
1272
+ const accounts = createAccountAdministration(integrationDatabase, auth);
1273
+ const app = new OpenAPIHono({ defaultHook: (result) => {
1274
+ if (!result.success) return Response.json({ error: {
1275
+ code: "validation_error",
1276
+ message: "Invalid request",
1277
+ details: result.error.issues.map((issue) => ({
1278
+ path: issue.path.join("."),
1279
+ message: issue.message
1280
+ }))
1281
+ } }, { status: 400 });
1282
+ } });
1283
+ app.use("*", secureHeaders());
1284
+ app.use("*", bodyLimit({
1285
+ maxSize: 15e6,
1286
+ onError: () => Response.json({ error: {
1287
+ code: "payload_too_large",
1288
+ message: "The request exceeds the upload limit"
1289
+ } }, { status: 413 })
1290
+ }));
1291
+ app.onError((error) => {
1292
+ if (error instanceof APIError && error.statusCode >= 400 && error.statusCode < 500) return Response.json({ error: {
1293
+ code: typeof error.body?.code === "string" ? error.body.code : "authentication_error",
1294
+ message: typeof error.body?.message === "string" ? error.body.message : "Authentication request failed"
1295
+ } }, { status: error.statusCode });
1296
+ if (error instanceof ApiError) {
1297
+ const retry = error.status === 429 && error.details && typeof error.details === "object" && "retryAfterSeconds" in error.details ? String(error.details.retryAfterSeconds) : void 0;
1298
+ return Response.json({ error: {
1299
+ status: error.status,
1300
+ code: error.code,
1301
+ message: error.message,
1302
+ details: error.details
1303
+ } }, {
1304
+ status: error.status,
1305
+ headers: retry ? { "Retry-After": retry } : {}
1306
+ });
1307
+ }
1308
+ if (error instanceof z$1.ZodError) return Response.json({ error: {
1309
+ code: "validation_error",
1310
+ message: "Invalid request",
1311
+ details: error.issues.map((issue) => ({
1312
+ path: issue.path.join("."),
1313
+ message: issue.message
1314
+ }))
1315
+ } }, { status: 400 });
1316
+ if (error instanceof SyntaxError || error instanceof HTTPException) return Response.json({ error: {
1317
+ code: "invalid_request",
1318
+ message: "The request could not be parsed"
1319
+ } }, { status: error instanceof HTTPException ? error.status : 400 });
1320
+ console.error("Kanvil request failed", { name: error.name });
1321
+ return Response.json({ error: {
1322
+ code: "internal_error",
1323
+ message: "The request could not be completed"
1324
+ } }, { status: 500 });
1325
+ });
1326
+ app.notFound(() => Response.json({ error: {
1327
+ code: "not_found",
1328
+ message: "The requested endpoint was not found"
1329
+ } }, { status: 404 }));
1330
+ app.get("/healthz", async () => {
1331
+ await database.pool.query("SELECT 1");
1332
+ return Response.json({ status: "ok" });
1333
+ });
1334
+ const setupRequired = async () => (await database.pool.query("SELECT count(*)::int AS count FROM \"user\"")).rows[0]?.count === 0;
1335
+ app.get("/api/v1/info", async () => Response.json({
1336
+ ...await instance.read().then((row) => ({
1337
+ name: row.name,
1338
+ description: row.description
1339
+ })),
1340
+ version: "0.1.0",
1341
+ setupRequired: await setupRequired(),
1342
+ push: config.KANVIL_PUSH_ENABLED,
1343
+ oidc: config.OIDC_DISCOVERY_URL ? {
1344
+ id: "oidc",
1345
+ name: config.OIDC_NAME
1346
+ } : null
1347
+ }));
1348
+ app.post("/api/v1/setup", async (c) => {
1349
+ access.checkOrigin(c.req.raw);
1350
+ const input = setupInput.parse(await c.req.json());
1351
+ if (!config.KANVIL_SETUP_TOKEN || !timingSafeEqual(digest(input.token), digest(config.KANVIL_SETUP_TOKEN))) fail(403, "invalid_setup_token", "The setup token is invalid");
1352
+ await database.db.transaction(async (tx) => {
1353
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(7361080)`);
1354
+ if (!await setupRequired()) fail(409, "setup_complete", "Initial setup is already complete");
1355
+ const created = await auth.api.createUser({ body: {
1356
+ email: input.email,
1357
+ name: input.name,
1358
+ password: input.password,
1359
+ role: "admin"
1360
+ } });
1361
+ const workspace = one(await tx.select().from(workspaces).where(eq(workspaces.slug, "kanvil")));
1362
+ await tx.insert(workspaceMembers).values({
1363
+ workspaceId: workspace.id,
1364
+ userId: created.user.id,
1365
+ role: "admin"
1366
+ });
1367
+ });
1368
+ return Response.json({ ok: true }, { status: 201 });
1369
+ });
1370
+ app.all("/api/auth/api-key/*", () => Response.json({ error: {
1371
+ code: "not_found",
1372
+ message: "Use the Kanvil credentials API"
1373
+ } }, { status: 404 }));
1374
+ app.on(["GET", "POST"], ["/api/auth/*", "/.well-known/*"], (c) => accounts.handleAuth(c.req.raw));
1375
+ const writes = createWriteLimiter(config.KANVIL_SESSION_WRITE_LIMIT);
1376
+ const mcpHandler = createMcp(auth, domain, access, config, (key) => writes.consume(key));
1377
+ app.all("/mcp", (c) => mcpHandler(c.req.raw));
1378
+ app.use("/api/v1/*", async (c, next) => {
1379
+ access.checkOrigin(c.req.raw);
1380
+ const actor = await access.authenticate(c.req.raw);
1381
+ c.set("actor", actor);
1382
+ if (!actor.machine && ![
1383
+ "GET",
1384
+ "HEAD",
1385
+ "OPTIONS"
1386
+ ].includes(c.req.method)) writes.consume(actor.id);
1387
+ await next();
1388
+ });
1389
+ app.use("/api/v1/admin/*", async (c, next) => {
1390
+ requireInstanceAdmin(c.get("actor"));
1391
+ await next();
1392
+ });
1393
+ registerInstanceRoutes(app, database, instance, blobs, search, realtime);
1394
+ accounts.register(app);
1395
+ app.openAPIRegistry.registerPath({
1396
+ method: "get",
1397
+ path: "/api/v1/projects/{projectId}/icon",
1398
+ security: [{ sessionCookie: [] }],
1399
+ request: { params: z$1.object({ projectId: z$1.string() }) },
1400
+ responses: {
1401
+ 200: {
1402
+ description: "Private project icon",
1403
+ content: {
1404
+ "image/png": { schema: z$1.string() },
1405
+ "image/jpeg": { schema: z$1.string() },
1406
+ "image/webp": { schema: z$1.string() }
1407
+ }
1408
+ },
1409
+ 404: { description: "Project icon unavailable" }
1410
+ }
1411
+ });
1412
+ app.get("/api/v1/projects/:projectId/icon", async (c) => {
1413
+ const project = await domain.execute("get_project", { projectId: c.req.param("projectId") }, c.get("actor"));
1414
+ const row = one(await database.db.select({ objectId: projects.iconObjectId }).from(projects).where(eq(projects.id, project.id)));
1415
+ if (!row.objectId) fail(404, "not_found", "This project has no uploaded icon");
1416
+ return imageResponse(await blobs.get(row.objectId));
1417
+ });
1418
+ app.get("/api/v1/credentials", async (c) => {
1419
+ const identity = c.get("actor");
1420
+ if (identity.machine) fail(403, "forbidden", "Use a signed-in account to manage credentials");
1421
+ const rows = await database.db.select({
1422
+ id: apikey.id,
1423
+ name: apikey.name,
1424
+ start: apikey.start,
1425
+ enabled: apikey.enabled,
1426
+ expiresAt: apikey.expiresAt,
1427
+ createdAt: apikey.createdAt,
1428
+ permissions: apikey.permissions,
1429
+ metadata: apikey.metadata
1430
+ }).from(apikey).where(eq(apikey.referenceId, identity.id)).orderBy(desc(apikey.createdAt));
1431
+ return Response.json(json(rows));
1432
+ });
1433
+ app.post("/api/v1/credentials", async (c) => {
1434
+ const identity = c.get("actor");
1435
+ if (identity.machine) fail(403, "forbidden", "Use a signed-in account to manage credentials");
1436
+ const input = keyInput.parse(await c.req.json()), allowed = await domain.accessibleProjects(identity);
1437
+ if (input.projectIds.some((id) => !allowed.some((row) => row.id === id))) fail(403, "forbidden", "Choose projects you can access");
1438
+ const key = await auth.api.createApiKey({ body: {
1439
+ userId: identity.id,
1440
+ name: input.name,
1441
+ prefix: "kv_",
1442
+ metadata: { projectIds: input.projectIds },
1443
+ permissions: { kanvil: input.scopes },
1444
+ expiresIn: input.expiresIn,
1445
+ rateLimitEnabled: true,
1446
+ rateLimitMax: 600,
1447
+ rateLimitTimeWindow: 6e4
1448
+ } });
1449
+ return Response.json({
1450
+ id: key.id,
1451
+ key: key.key,
1452
+ name: key.name,
1453
+ expiresAt: key.expiresAt
1454
+ }, { status: 201 });
1455
+ });
1456
+ app.delete("/api/v1/credentials/:id", async (c) => {
1457
+ const identity = c.get("actor");
1458
+ if (identity.machine) fail(403, "forbidden", "Use a signed-in account to manage credentials");
1459
+ const id = c.req.param("id");
1460
+ one(await database.db.select({ id: apikey.id }).from(apikey).where(and(eq(apikey.id, id), eq(apikey.referenceId, identity.id))));
1461
+ await auth.api.deleteApiKey({
1462
+ body: { keyId: id },
1463
+ headers: c.req.raw.headers
1464
+ });
1465
+ return Response.json({ ok: true });
1466
+ });
1467
+ app.get("/api/v1/snapshot", async (c) => Response.json(await domain.snapshot(c.get("actor"), z$1.string().min(1).parse(c.req.query("projectId")))));
1468
+ app.get("/api/v1/checkpoint", async () => {
1469
+ const row = one(await database.db.select().from(eventClock).where(eq(eventClock.id, 1)));
1470
+ return Response.json({ cursor: row.value.toString() });
1471
+ });
1472
+ app.get("/api/v1/projects/:projectId/attachments/:id/content", async (c) => {
1473
+ const id = z$1.uuid().parse(c.req.param("id"));
1474
+ const row = one(await database.db.select().from(attachments).where(eq(attachments.id, id)));
1475
+ const authorized = await domain.execute("list_attachments", {
1476
+ projectId: c.req.param("projectId"),
1477
+ ticketId: row.ticketId
1478
+ }, c.get("actor"));
1479
+ requireRow(authorized.find((item) => item.id === id));
1480
+ const bytes = row.objectId ? (await blobs.get(row.objectId)).data : one(await database.db.select({ data: attachmentContent.data }).from(attachmentContent).where(eq(attachmentContent.id, id))).data;
1481
+ if (bytes.length !== row.size) fail(500, "attachment_corrupt", "Attachment integrity check failed");
1482
+ return new Response(bytes, { headers: {
1483
+ "Content-Type": row.contentType,
1484
+ "Content-Length": String(row.size),
1485
+ "Content-Disposition": `attachment; filename="download"; filename*=UTF-8''${encodeURIComponent(row.name)}`,
1486
+ "Cache-Control": "no-store",
1487
+ "Content-Security-Policy": "sandbox"
1488
+ } });
1489
+ });
1490
+ app.get("/api/v1/notifications/preferences", async (c) => {
1491
+ const row = (await database.db.select().from(notificationPreferences).where(eq(notificationPreferences.userId, c.get("actor").id)))[0];
1492
+ return Response.json(notificationPreferencesSchema.parse(row ? {
1493
+ inApp: row.inApp,
1494
+ push: row.push
1495
+ } : {}));
1496
+ });
1497
+ app.put("/api/v1/notifications/preferences", async (c) => {
1498
+ const identity = requireSession(c.get("actor"));
1499
+ const input = notificationPreferencesInput.parse(await c.req.json());
1500
+ const row = one(await database.db.insert(notificationPreferences).values({
1501
+ userId: identity.id,
1502
+ ...input
1503
+ }).onConflictDoUpdate({
1504
+ target: notificationPreferences.userId,
1505
+ set: input
1506
+ }).returning({
1507
+ inApp: notificationPreferences.inApp,
1508
+ push: notificationPreferences.push
1509
+ }));
1510
+ return Response.json(notificationPreferencesSchema.parse(row));
1511
+ });
1512
+ app.post("/api/v1/notifications/device", async (c) => {
1513
+ const identity = requireSession(c.get("actor"));
1514
+ if (!config.KANVIL_PUSH_ENABLED) fail(503, "push_disabled", "Push notifications are disabled on this server");
1515
+ const input = pushDeviceInput.parse(await c.req.json());
1516
+ if (!Expo.isExpoPushToken(input.token)) fail(400, "invalid_push_token", "Use an Expo push token");
1517
+ const current = requireRow((await auth.api.getSession({ headers: c.req.raw.headers }))?.session);
1518
+ await database.db.transaction(async (tx) => {
1519
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(7361080)`);
1520
+ const existing = (await tx.select().from(pushDevices).where(eq(pushDevices.token, input.token)))[0];
1521
+ if (existing?.userId === identity.id && existing.sessionId === current.id) return;
1522
+ if (existing) await tx.delete(pushDevices).where(eq(pushDevices.id, existing.id));
1523
+ await tx.insert(pushDevices).values({
1524
+ userId: identity.id,
1525
+ sessionId: current.id,
1526
+ token: input.token
1527
+ });
1528
+ });
1529
+ return Response.json({ ok: true });
1530
+ });
1531
+ app.delete("/api/v1/notifications/device", async (c) => {
1532
+ const identity = requireSession(c.get("actor")), input = pushDeviceInput.parse(await c.req.json());
1533
+ await database.db.delete(pushDevices).where(and(eq(pushDevices.userId, identity.id), eq(pushDevices.token, input.token)));
1534
+ return Response.json({ ok: true });
1535
+ });
1536
+ app.get("/api/v1/notifications", async (c) => {
1537
+ const identity = c.get("actor"), allowed = await domain.accessibleProjects(identity);
1538
+ if (!allowed.length) return Response.json([]);
1539
+ return Response.json(json(await database.db.select({
1540
+ id: notifications.id,
1541
+ readAt: notifications.readAt,
1542
+ event: events
1543
+ }).from(notifications).innerJoin(events, eq(events.id, notifications.eventId)).where(and(eq(notifications.userId, identity.id), eq(notifications.inbox, true), inArray(notifications.projectId, allowed.map((row) => row.id)))).orderBy(desc(notifications.createdAt)).limit(100)));
1544
+ });
1545
+ app.post("/api/v1/notifications/read", async (c) => {
1546
+ requireSession(c.get("actor"));
1547
+ const input = z$1.object({ ids: z$1.array(z$1.uuid()).min(1).max(100) }).parse(await c.req.json());
1548
+ await database.db.update(notifications).set({ readAt: /* @__PURE__ */ new Date() }).where(and(eq(notifications.userId, c.get("actor").id), inArray(notifications.id, input.ids)));
1549
+ return Response.json({ ok: true });
1550
+ });
1551
+ app.get("/api/v1/events", async (c) => {
1552
+ const projectId = c.req.query("projectId");
1553
+ const raw = c.req.query("cursor") ?? c.req.header("last-event-id");
1554
+ let cursor = raw === void 0 ? one(await database.db.select().from(eventClock).where(eq(eventClock.id, 1))).value : BigInt(z$1.string().regex(/^\d{1,19}$/).parse(raw));
1555
+ await domain.readEvents(c.get("actor"), cursor, projectId);
1556
+ c.header("X-Accel-Buffering", "no");
1557
+ c.header("Cache-Control", "no-cache, no-transform");
1558
+ return streamSSE(c, async (stream) => {
1559
+ let active = true, busy = false, again = false;
1560
+ let finish;
1561
+ const done = new Promise((resolve) => {
1562
+ finish = resolve;
1563
+ });
1564
+ const stop = () => {
1565
+ if (!active) return;
1566
+ active = false;
1567
+ clearInterval(heartbeat);
1568
+ unsubscribe();
1569
+ unsubscribeClose();
1570
+ finish();
1571
+ };
1572
+ const pump = async () => {
1573
+ if (!active) return;
1574
+ if (busy) {
1575
+ again = true;
1576
+ return;
1577
+ }
1578
+ busy = true;
1579
+ try {
1580
+ do {
1581
+ if (!active) break;
1582
+ again = false;
1583
+ const identity = await access.revalidate(c.req.raw, c.get("actor"));
1584
+ const batch = await domain.readEvents(identity, cursor, projectId);
1585
+ for (const event of batch.items) {
1586
+ if (!active) break;
1587
+ await stream.writeSSE({
1588
+ event: "change",
1589
+ id: event.id.toString(),
1590
+ data: JSON.stringify(json(event))
1591
+ });
1592
+ }
1593
+ cursor = batch.cursor;
1594
+ if (active) await stream.writeSSE({
1595
+ event: "checkpoint",
1596
+ id: cursor.toString(),
1597
+ data: JSON.stringify({ cursor: cursor.toString() })
1598
+ });
1599
+ if (batch.items.length === 200) again = true;
1600
+ } while (again);
1601
+ } catch (error) {
1602
+ if (active && error instanceof ApiError) await stream.writeSSE({
1603
+ event: "error",
1604
+ data: JSON.stringify({
1605
+ status: error.status,
1606
+ code: error.code,
1607
+ message: error.message
1608
+ })
1609
+ }).catch(() => {});
1610
+ stop();
1611
+ } finally {
1612
+ busy = false;
1613
+ }
1614
+ };
1615
+ const schedule = () => {
1616
+ pump();
1617
+ };
1618
+ const unsubscribe = realtime.subscribe(schedule), unsubscribeClose = realtime.onClose(stop);
1619
+ const heartbeat = setInterval(schedule, 15e3);
1620
+ stream.onAbort(stop);
1621
+ await pump();
1622
+ await done;
1623
+ });
1624
+ });
1625
+ for (const name of Object.keys(operations).filter(isOperation)) {
1626
+ const spec = operations[name], params = {}, rest = {};
1627
+ const keys = [...spec.path.matchAll(/\{(\w+)\}/g)].map((match) => match[1]);
1628
+ for (const [key, value] of Object.entries(spec.input.shape)) if (keys.includes(key)) params[key] = value;
1629
+ else rest[key] = value;
1630
+ const method = spec.method === "GET" ? "get" : spec.method === "POST" ? "post" : spec.method === "PATCH" ? "patch" : "delete";
1631
+ const route = createRoute({
1632
+ method,
1633
+ path: `/api/v1${spec.path}`,
1634
+ operationId: name,
1635
+ description: spec.description,
1636
+ security: [{ bearerAuth: [] }],
1637
+ request: {
1638
+ params: z$1.object(params),
1639
+ ...method === "get" ? { query: z$1.object(rest).strict() } : { body: {
1640
+ content: { "application/json": { schema: z$1.object(rest).strict() } },
1641
+ required: true
1642
+ } }
1643
+ },
1644
+ responses: {
1645
+ 200: {
1646
+ description: "Success",
1647
+ content: { "application/json": { schema: spec.output } }
1648
+ },
1649
+ 400: {
1650
+ description: "Invalid input",
1651
+ content: { "application/json": { schema: errorSchema } }
1652
+ },
1653
+ 401: { description: "Authentication required" },
1654
+ 403: { description: "Insufficient permissions" },
1655
+ 404: { description: "Not found" },
1656
+ 409: { description: "Version, ownership, or dependency conflict" },
1657
+ 429: { description: "Too many writes from this session; see Retry-After" }
1658
+ }
1659
+ });
1660
+ app.openapi(route, async (c) => {
1661
+ const values = spec.method === "GET" ? c.req.query() : z$1.record(z$1.string(), z$1.unknown()).parse(await c.req.json());
1662
+ return Response.json(await domain.execute(name, {
1663
+ ...values,
1664
+ ...c.req.param()
1665
+ }, c.get("actor")));
1666
+ });
1667
+ }
1668
+ app.openAPIRegistry.registerComponent("securitySchemes", "bearerAuth", {
1669
+ type: "http",
1670
+ scheme: "bearer",
1671
+ description: "Kanvil API credential. Signed-in browser/mobile sessions are also accepted."
1672
+ });
1673
+ app.doc31("/api/openapi.json", {
1674
+ openapi: "3.1.0",
1675
+ info: {
1676
+ title: "Kanvil API",
1677
+ version: "0.1.0"
1678
+ },
1679
+ servers: [{ url: config.KANVIL_URL }]
1680
+ });
1681
+ const webRoot = fileURLToPath(new URL("../../web/dist", import.meta.url));
1682
+ app.use("*", async (c, next) => {
1683
+ await next();
1684
+ if (!c.res.headers.get("Content-Type")?.startsWith("text/html")) return;
1685
+ c.header("Content-Length", void 0);
1686
+ const imageUrl = new URL("/brand/social.png", config.KANVIL_URL).href;
1687
+ const html = (await c.res.text()).replaceAll("content=\"/brand/social.png\"", `content="${imageUrl}"`);
1688
+ c.res = new Response(html, {
1689
+ status: c.res.status,
1690
+ headers: c.res.headers
1691
+ });
1692
+ });
1693
+ app.on("GET", "*", serveStatic({
1694
+ root: webRoot,
1695
+ onFound: (path, c) => {
1696
+ c.header("Cache-Control", path.endsWith("/index.html") ? "no-store" : c.req.path.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "public, max-age=3600");
1697
+ }
1698
+ }));
1699
+ app.get("*", async (c, next) => {
1700
+ if (c.req.path.startsWith("/api/") || c.req.path.startsWith("/.well-known/") || c.req.path === "/mcp" || c.req.path.includes(".")) return next();
1701
+ c.header("Cache-Control", "no-store");
1702
+ return serveStatic({
1703
+ root: webRoot,
1704
+ path: "index.html"
1705
+ })(c, next);
1706
+ });
1707
+ return {
1708
+ app,
1709
+ auth,
1710
+ domain,
1711
+ instance,
1712
+ blobs,
1713
+ search,
1714
+ realtime,
1715
+ close: async () => {
1716
+ unsubscribeJobs();
1717
+ await realtime.close();
1718
+ await search.close();
1719
+ await blobs.close();
1720
+ await integrationDatabase.pool.end();
1721
+ await push.close();
1722
+ }
1723
+ };
1724
+ }
1725
+ //#endregion
1726
+ //#region src/index.ts
1727
+ const config = readConfig();
1728
+ const database = createDatabase(config.DATABASE_URL);
1729
+ await migrateDatabase(database);
1730
+ const application = await createApplication(database, config);
1731
+ const server = serve({
1732
+ fetch: application.app.fetch,
1733
+ hostname: config.HOST,
1734
+ port: config.PORT
1735
+ }, () => {
1736
+ console.info(`Kanvil is listening on ${config.HOST}:${config.PORT}`);
1737
+ });
1738
+ let stopping = false;
1739
+ async function shutdown() {
1740
+ if (stopping) return;
1741
+ stopping = true;
1742
+ await application.close();
1743
+ server.close(async () => {
1744
+ await database.pool.end();
1745
+ });
1746
+ }
1747
+ process.on("SIGINT", () => {
1748
+ shutdown();
1749
+ });
1750
+ process.on("SIGTERM", () => {
1751
+ shutdown();
1752
+ });
1753
+ //#endregion
1754
+ export {};