@hiai-gg/hiai-docs 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/.all-contributorsrc +18 -0
  2. package/.claude/settings.local.json +61 -0
  3. package/.dockerignore +113 -0
  4. package/.env.example +68 -0
  5. package/.github/FUNDING.yml +5 -0
  6. package/.github/ISSUE_TEMPLATE/bug_report.md +74 -0
  7. package/.github/ISSUE_TEMPLATE/feature_request.md +78 -0
  8. package/.github/dependabot.yml +136 -0
  9. package/.github/pull_request_template.md +96 -0
  10. package/.github/workflows/ci.yml +283 -0
  11. package/AGENTS.md +237 -0
  12. package/CODE_OF_CONDUCT.md +134 -0
  13. package/CONTRIBUTING.md +77 -0
  14. package/Caddyfile +50 -0
  15. package/Dockerfile.backend +60 -0
  16. package/LICENSE +21 -0
  17. package/README.md +284 -0
  18. package/RELEASE_CHECKLIST.md +34 -0
  19. package/SECURITY.md +60 -0
  20. package/backend/package.json +43 -0
  21. package/backend/src/__tests__/auth-helpers.test.ts +51 -0
  22. package/backend/src/__tests__/chunker.test.ts +65 -0
  23. package/backend/src/__tests__/config.test.ts +91 -0
  24. package/backend/src/__tests__/csrf.test.ts +91 -0
  25. package/backend/src/__tests__/embedding.test.ts +48 -0
  26. package/backend/src/__tests__/rate-limit.test.ts +46 -0
  27. package/backend/src/__tests__/routes.test.ts +38 -0
  28. package/backend/src/__tests__/schema.test.ts +31 -0
  29. package/backend/src/__tests__/validation.test.ts +556 -0
  30. package/backend/src/api/middleware/auth.ts +56 -0
  31. package/backend/src/api/middleware/csrf.ts +91 -0
  32. package/backend/src/api/middleware/rate-limit.ts +77 -0
  33. package/backend/src/api/middleware/webhook-verify.ts +22 -0
  34. package/backend/src/api/routes/attachments.ts +280 -0
  35. package/backend/src/api/routes/auth.ts +52 -0
  36. package/backend/src/api/routes/collaboration.ts +121 -0
  37. package/backend/src/api/routes/documents.ts +664 -0
  38. package/backend/src/api/routes/folders.ts +226 -0
  39. package/backend/src/api/routes/search.ts +354 -0
  40. package/backend/src/api/routes/share.ts +512 -0
  41. package/backend/src/api/routes/tags.ts +247 -0
  42. package/backend/src/api/routes/versions.ts +99 -0
  43. package/backend/src/api/routes/webhooks.ts +43 -0
  44. package/backend/src/embedding/chunker.ts +74 -0
  45. package/backend/src/embedding/index.ts +117 -0
  46. package/backend/src/embedding/providers/ollama.ts +63 -0
  47. package/backend/src/embedding/providers/openrouter.ts +71 -0
  48. package/backend/src/embedding/utils.ts +13 -0
  49. package/backend/src/embedding/worker.ts +89 -0
  50. package/backend/src/index.ts +147 -0
  51. package/backend/src/lib/auth-helpers.ts +27 -0
  52. package/backend/src/lib/auth.ts +35 -0
  53. package/backend/src/lib/config.ts +73 -0
  54. package/backend/src/lib/db.ts +7 -0
  55. package/backend/src/lib/embedding-queue.ts +12 -0
  56. package/backend/src/lib/logger.ts +18 -0
  57. package/backend/src/lib/markdown-to-doc.ts +45 -0
  58. package/backend/src/lib/minio.ts +46 -0
  59. package/backend/src/lib/redis.ts +19 -0
  60. package/backend/src/lib/yjs-provider.ts +182 -0
  61. package/backend/tests/integration/_harness.ts +754 -0
  62. package/backend/tests/integration/auth.test.ts +296 -0
  63. package/backend/tests/integration/routes.documents.test.ts +459 -0
  64. package/backend/tests/integration/routes.folders.test.ts +337 -0
  65. package/backend/tests/integration/routes.search.test.ts +322 -0
  66. package/backend/tests/integration/routes.share.test.ts +773 -0
  67. package/backend/tests/integration/routes.tags.test.ts +425 -0
  68. package/backend/tests/integration/routes.versions.test.ts +233 -0
  69. package/backend/tsconfig.json +18 -0
  70. package/docker-compose.yml +218 -0
  71. package/docs/API.md +328 -0
  72. package/docs/ARCHITECTURE.md +75 -0
  73. package/docs/DEPLOYMENT.md +113 -0
  74. package/docs/PRODUCTION_STATUS.md +61 -0
  75. package/docs/openapi.json +385 -0
  76. package/frontend/.svelte-kit.old/ambient.d.ts +230 -0
  77. package/frontend/.svelte-kit.old/env.d.ts +1 -0
  78. package/frontend/.svelte-kit.old/generated/client/app.js +46 -0
  79. package/frontend/.svelte-kit.old/generated/client/matchers.js +1 -0
  80. package/frontend/.svelte-kit.old/generated/client/nodes/0.js +3 -0
  81. package/frontend/.svelte-kit.old/generated/client/nodes/1.js +1 -0
  82. package/frontend/.svelte-kit.old/generated/client/nodes/10.js +3 -0
  83. package/frontend/.svelte-kit.old/generated/client/nodes/2.js +1 -0
  84. package/frontend/.svelte-kit.old/generated/client/nodes/3.js +1 -0
  85. package/frontend/.svelte-kit.old/generated/client/nodes/4.js +1 -0
  86. package/frontend/.svelte-kit.old/generated/client/nodes/5.js +3 -0
  87. package/frontend/.svelte-kit.old/generated/client/nodes/6.js +1 -0
  88. package/frontend/.svelte-kit.old/generated/client/nodes/7.js +3 -0
  89. package/frontend/.svelte-kit.old/generated/client/nodes/8.js +1 -0
  90. package/frontend/.svelte-kit.old/generated/client/nodes/9.js +3 -0
  91. package/frontend/.svelte-kit.old/generated/root.js +3 -0
  92. package/frontend/.svelte-kit.old/generated/root.svelte +80 -0
  93. package/frontend/.svelte-kit.old/generated/server/internal.js +55 -0
  94. package/frontend/.svelte-kit.old/non-ambient.d.ts +59 -0
  95. package/frontend/.svelte-kit.old/tsconfig.json +59 -0
  96. package/frontend/.svelte-kit.old/types/route_meta_data.json +40 -0
  97. package/frontend/.svelte-kit.old/types/src/routes/$types.d.ts +21 -0
  98. package/frontend/.svelte-kit.old/types/src/routes/(app)/$types.d.ts +30 -0
  99. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/$types.d.ts +27 -0
  100. package/frontend/.svelte-kit.old/types/src/routes/(app)/docs/[id]/proxy+page.ts +25 -0
  101. package/frontend/.svelte-kit.old/types/src/routes/api/[...path]/$types.d.ts +10 -0
  102. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/$types.d.ts +27 -0
  103. package/frontend/.svelte-kit.old/types/src/routes/folders/[id]/proxy+page.ts +15 -0
  104. package/frontend/.svelte-kit.old/types/src/routes/login/$types.d.ts +17 -0
  105. package/frontend/.svelte-kit.old/types/src/routes/register/$types.d.ts +17 -0
  106. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/$types.d.ts +20 -0
  107. package/frontend/.svelte-kit.old/types/src/routes/s/[token]/proxy+page.ts +6 -0
  108. package/frontend/.svelte-kit.old/types/src/routes/search/$types.d.ts +19 -0
  109. package/frontend/.svelte-kit.old/types/src/routes/search/proxy+page.ts +26 -0
  110. package/frontend/.svelte-kit.old/types/src/routes/settings/$types.d.ts +17 -0
  111. package/frontend/Dockerfile +44 -0
  112. package/frontend/biome.json +40 -0
  113. package/frontend/components.json +18 -0
  114. package/frontend/messages/en.json +434 -0
  115. package/frontend/package.json +70 -0
  116. package/frontend/project.inlang/settings.json +12 -0
  117. package/frontend/src/app.css +6 -0
  118. package/frontend/src/app.d.ts +13 -0
  119. package/frontend/src/app.html +30 -0
  120. package/frontend/src/hooks.server.ts +10 -0
  121. package/frontend/src/hooks.ts +10 -0
  122. package/frontend/src/lib/api/attachments.ts +45 -0
  123. package/frontend/src/lib/api/client.test.ts +15 -0
  124. package/frontend/src/lib/api/client.ts +57 -0
  125. package/frontend/src/lib/api/documents.ts +83 -0
  126. package/frontend/src/lib/api/folders.ts +180 -0
  127. package/frontend/src/lib/api/search.test.ts +52 -0
  128. package/frontend/src/lib/api/search.ts +128 -0
  129. package/frontend/src/lib/api/settings.ts +95 -0
  130. package/frontend/src/lib/api/share.ts +71 -0
  131. package/frontend/src/lib/api/tags.test.ts +91 -0
  132. package/frontend/src/lib/api/tags.ts +87 -0
  133. package/frontend/src/lib/auth-client.ts +10 -0
  134. package/frontend/src/lib/collaboration.ts +63 -0
  135. package/frontend/src/lib/components/AttachmentUpload.svelte +110 -0
  136. package/frontend/src/lib/components/DatePicker.svelte +322 -0
  137. package/frontend/src/lib/components/DocumentCard.svelte +166 -0
  138. package/frontend/src/lib/components/EmptyState.svelte +49 -0
  139. package/frontend/src/lib/components/FolderCard.svelte +93 -0
  140. package/frontend/src/lib/components/ScrollToTop.svelte +72 -0
  141. package/frontend/src/lib/components/SearchBar.svelte +47 -0
  142. package/frontend/src/lib/components/SearchResult.svelte +115 -0
  143. package/frontend/src/lib/components/SettingsDialog.svelte +271 -0
  144. package/frontend/src/lib/components/ShareDialog.svelte +158 -0
  145. package/frontend/src/lib/components/ShareLink.svelte +98 -0
  146. package/frontend/src/lib/components/TagCreateDialog.svelte +284 -0
  147. package/frontend/src/lib/components/VersionDiff.svelte +55 -0
  148. package/frontend/src/lib/components/VersionHistory.svelte +96 -0
  149. package/frontend/src/lib/components/editor/DocumentTitle.svelte +87 -0
  150. package/frontend/src/lib/components/editor/EditorToolbar.svelte +1367 -0
  151. package/frontend/src/lib/components/editor/HiAiEditor.svelte +531 -0
  152. package/frontend/src/lib/components/editor/LinkDialog.svelte +134 -0
  153. package/frontend/src/lib/components/editor/MarkdownToggle.svelte +88 -0
  154. package/frontend/src/lib/components/editor/editorExtensions.ts +53 -0
  155. package/frontend/src/lib/components/editor/markdown.ts +38 -0
  156. package/frontend/src/lib/components/sidebar/FolderTree.svelte +731 -0
  157. package/frontend/src/lib/components/sidebar/RecentDocs.svelte +311 -0
  158. package/frontend/src/lib/components/sidebar/Sidebar.svelte +156 -0
  159. package/frontend/src/lib/components/sidebar/TagList.svelte +200 -0
  160. package/frontend/src/lib/components/ui/confirm-dialog/ConfirmDialog.svelte +76 -0
  161. package/frontend/src/lib/components/ui/confirm-dialog/index.ts +1 -0
  162. package/frontend/src/lib/stores/tag-store.svelte.ts +56 -0
  163. package/frontend/src/lib/stores/theme.svelte.ts +97 -0
  164. package/frontend/src/lib/svelte.d.ts +6 -0
  165. package/frontend/src/lib/types.ts +44 -0
  166. package/frontend/src/lib/utils/clipboard.ts +17 -0
  167. package/frontend/src/lib/utils/strip-markdown.ts +59 -0
  168. package/frontend/src/lib/utils.ts +33 -0
  169. package/frontend/src/routes/(app)/+layout.svelte +17 -0
  170. package/frontend/src/routes/(app)/+page.server.ts +10 -0
  171. package/frontend/src/routes/(app)/+page.svelte +303 -0
  172. package/frontend/src/routes/(app)/docs/[id]/+page.server.ts +10 -0
  173. package/frontend/src/routes/(app)/docs/[id]/+page.svelte +1108 -0
  174. package/frontend/src/routes/(app)/docs/[id]/+page.ts +24 -0
  175. package/frontend/src/routes/(app)/search/+page.svelte +593 -0
  176. package/frontend/src/routes/(app)/search/+page.ts +25 -0
  177. package/frontend/src/routes/+error.svelte +12 -0
  178. package/frontend/src/routes/+layout.svelte +18 -0
  179. package/frontend/src/routes/+layout.ts +2 -0
  180. package/frontend/src/routes/api/[...path]/+server.ts +111 -0
  181. package/frontend/src/routes/folders/[id]/+page.server.ts +10 -0
  182. package/frontend/src/routes/folders/[id]/+page.svelte +319 -0
  183. package/frontend/src/routes/folders/[id]/+page.ts +14 -0
  184. package/frontend/src/routes/login/+page.svelte +90 -0
  185. package/frontend/src/routes/register/+page.svelte +97 -0
  186. package/frontend/src/routes/s/[token]/+page.svelte +496 -0
  187. package/frontend/src/routes/s/[token]/+page.ts +5 -0
  188. package/frontend/src/routes/settings/+page.svelte +175 -0
  189. package/frontend/static/favicon.png +0 -0
  190. package/frontend/static/logo.png +0 -0
  191. package/frontend/svelte.config.js +15 -0
  192. package/frontend/tsconfig.json +15 -0
  193. package/frontend/vite.config.ts +25 -0
  194. package/init.sql +9 -0
  195. package/logo.png +0 -0
  196. package/package.json +39 -0
  197. package/package.public.json +39 -0
  198. package/packages/db/drizzle.config.ts +10 -0
  199. package/packages/db/package.json +30 -0
  200. package/packages/db/src/client.ts +9 -0
  201. package/packages/db/src/index.ts +2 -0
  202. package/packages/db/src/migrations/0000_nice_bedlam.sql +165 -0
  203. package/packages/db/src/migrations/0001_w2_3_test.sql +5 -0
  204. package/packages/db/src/migrations/0002_rename_content_json.sql +2 -0
  205. package/packages/db/src/migrations/meta/0000_snapshot.json +1331 -0
  206. package/packages/db/src/migrations/meta/0001_snapshot.json +1399 -0
  207. package/packages/db/src/migrations/meta/0002_snapshot.json +1399 -0
  208. package/packages/db/src/migrations/meta/_journal.json +27 -0
  209. package/packages/db/src/schema.ts +378 -0
  210. package/packages/db/tsconfig.json +17 -0
  211. package/scripts/export-openapi.ts +37 -0
  212. package/scripts/health-check.sh +75 -0
  213. package/scripts/migrate.sh +135 -0
  214. package/scripts/prework_backup.sh +25 -0
  215. package/scripts/release.sh +83 -0
  216. package/tsconfig.json +25 -0
@@ -0,0 +1,512 @@
1
+ import {
2
+ documents,
3
+ folders,
4
+ guestAccess,
5
+ shareLinks,
6
+ } from "@hiai-docs/db/schema";
7
+ import { and, eq, sql } from "drizzle-orm";
8
+ import { Elysia } from "elysia";
9
+ import { nanoid } from "nanoid";
10
+ import { z } from "zod";
11
+ import { getSessionUserId } from "../../lib/auth-helpers";
12
+ import { db } from "../../lib/db";
13
+ import { logger } from "../../lib/logger";
14
+ import { redis } from "../../lib/redis";
15
+
16
+ // ============================================
17
+ // Validation schemas
18
+ // ============================================
19
+
20
+ const createShareSchema = z
21
+ .object({
22
+ documentId: z.string().uuid().optional(),
23
+ folderId: z.string().uuid().optional(),
24
+ password: z.string().min(1).optional(),
25
+ expiresIn: z.enum(["1h", "1d", "7d", "30d", "never"]).default("never"),
26
+ })
27
+ .refine((d) => d.documentId || d.folderId, {
28
+ message: "Either documentId or folderId must be provided",
29
+ });
30
+
31
+ const addGuestSchema = z.object({
32
+ email: z.string().email("Invalid email address"),
33
+ });
34
+
35
+ // ============================================
36
+ // Expiry calculation
37
+ // ============================================
38
+
39
+ function calculateExpiresAt(
40
+ expiresIn: "1h" | "1d" | "7d" | "30d" | "never",
41
+ ): Date | null {
42
+ if (expiresIn === "never") return null;
43
+ const ms: Record<string, number> = {
44
+ "1h": 3_600_000,
45
+ "1d": 86_400_000,
46
+ "7d": 604_800_000,
47
+ "30d": 2_592_000_000,
48
+ };
49
+ return new Date(Date.now() + (ms[expiresIn] ?? 0));
50
+ }
51
+
52
+ // ============================================
53
+ // Redis-based rate limiter for public access
54
+ // ============================================
55
+
56
+ const RATE_LIMIT_MAX = 10;
57
+ const RATE_LIMIT_WINDOW_SEC = 60;
58
+
59
+ async function checkRateLimit(
60
+ ip: string,
61
+ ): Promise<{ allowed: boolean; retryAfter?: number }> {
62
+ const key = `hiai-docs:ratelimit:${ip}`;
63
+ try {
64
+ const count = await redis.incr(key);
65
+ if (count === 1) {
66
+ await redis.expire(key, RATE_LIMIT_WINDOW_SEC);
67
+ }
68
+ if (count > RATE_LIMIT_MAX) {
69
+ const ttl = await redis.ttl(key);
70
+ return {
71
+ allowed: false,
72
+ retryAfter: ttl > 0 ? ttl : RATE_LIMIT_WINDOW_SEC,
73
+ };
74
+ }
75
+ return { allowed: true };
76
+ } catch {
77
+ // If Redis is down, deny the request (fail-closed)
78
+ return { allowed: false, retryAfter: 60 };
79
+ }
80
+ }
81
+
82
+ // ============================================
83
+ // Helper: get client IP
84
+ // ============================================
85
+
86
+ function getClientIp(request: Request): string {
87
+ return (
88
+ request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ??
89
+ request.headers.get("x-real-ip") ??
90
+ "unknown"
91
+ );
92
+ }
93
+
94
+ // ============================================
95
+ // Routes
96
+ // ============================================
97
+
98
+ export const shareRoutes = new Elysia({ prefix: "/api/share" })
99
+
100
+ // POST /api/share — Create share link (auth required)
101
+ .post("/", async ({ request, set }) => {
102
+ const userId = await getSessionUserId(request.headers);
103
+ if (!userId) {
104
+ set.status = 401;
105
+ return { error: "Unauthorized" };
106
+ }
107
+
108
+ let body: unknown;
109
+ try {
110
+ body = await request.json();
111
+ } catch {
112
+ set.status = 400;
113
+ return { error: "Invalid JSON body" };
114
+ }
115
+
116
+ const parsed = createShareSchema.safeParse(body);
117
+ if (!parsed.success) {
118
+ set.status = 400;
119
+ return {
120
+ error: "Validation failed",
121
+ details: parsed.error.flatten().fieldErrors,
122
+ };
123
+ }
124
+
125
+ const { documentId, folderId, password, expiresIn } = parsed.data;
126
+
127
+ // Verify ownership of the target document or folder
128
+ if (documentId) {
129
+ const [doc] = await db
130
+ .select({ id: documents.id })
131
+ .from(documents)
132
+ .where(and(eq(documents.id, documentId), eq(documents.ownerId, userId)))
133
+ .limit(1);
134
+ if (!doc) {
135
+ set.status = 404;
136
+ return { error: "Document not found" };
137
+ }
138
+ }
139
+
140
+ if (folderId) {
141
+ const [folder] = await db
142
+ .select({ id: folders.id })
143
+ .from(folders)
144
+ .where(and(eq(folders.id, folderId), eq(folders.ownerId, userId)))
145
+ .limit(1);
146
+ if (!folder) {
147
+ set.status = 404;
148
+ return { error: "Folder not found" };
149
+ }
150
+ }
151
+
152
+ const token = nanoid(21);
153
+ const passwordHash = password ? await Bun.password.hash(password) : null;
154
+ const expiresAt = calculateExpiresAt(expiresIn);
155
+
156
+ const [link] = await db
157
+ .insert(shareLinks)
158
+ .values({
159
+ documentId: documentId ?? null,
160
+ folderId: folderId ?? null,
161
+ token,
162
+ passwordHash,
163
+ expiresAt,
164
+ createdBy: userId,
165
+ })
166
+ .returning();
167
+
168
+ if (!link) {
169
+ set.status = 500;
170
+ return { error: "Failed to create share link" };
171
+ }
172
+
173
+ logger.info(
174
+ { shareId: link.id, userId, documentId, folderId },
175
+ "Share link created",
176
+ );
177
+
178
+ return {
179
+ id: link.id,
180
+ token: link.token,
181
+ documentId: link.documentId,
182
+ folderId: link.folderId,
183
+ expiresAt: link.expiresAt?.toISOString() ?? null,
184
+ hasPassword: !!link.passwordHash,
185
+ createdAt: link.createdAt.toISOString(),
186
+ };
187
+ })
188
+
189
+ // GET /api/share — List share links for current user (auth required)
190
+ .get("/", async ({ request, set }) => {
191
+ const userId = await getSessionUserId(request.headers);
192
+ if (!userId) {
193
+ set.status = 401;
194
+ return { error: "Unauthorized" };
195
+ }
196
+
197
+ const links = await db
198
+ .select({
199
+ id: shareLinks.id,
200
+ token: shareLinks.token,
201
+ documentId: shareLinks.documentId,
202
+ folderId: shareLinks.folderId,
203
+ hasPassword: sql<boolean>`${shareLinks.passwordHash} IS NOT NULL`,
204
+ expiresAt: shareLinks.expiresAt,
205
+ createdAt: shareLinks.createdAt,
206
+ documentTitle: documents.title,
207
+ folderName: folders.name,
208
+ })
209
+ .from(shareLinks)
210
+ .leftJoin(documents, eq(shareLinks.documentId, documents.id))
211
+ .leftJoin(folders, eq(shareLinks.folderId, folders.id))
212
+ .where(eq(shareLinks.createdBy, userId))
213
+ .orderBy(shareLinks.createdAt);
214
+
215
+ return {
216
+ links: links.map((link) => ({
217
+ id: link.id,
218
+ token: link.token,
219
+ documentId: link.documentId,
220
+ folderId: link.folderId,
221
+ hasPassword: link.hasPassword,
222
+ expiresAt: link.expiresAt?.toISOString() ?? null,
223
+ createdAt: link.createdAt.toISOString(),
224
+ title: link.documentTitle ?? link.folderName ?? "Unknown",
225
+ type: link.documentId ? ("document" as const) : ("folder" as const),
226
+ })),
227
+ };
228
+ })
229
+
230
+ // GET /api/share/:token — Access shared content (PUBLIC, no auth)
231
+ .get("/:token", async ({ params, request, set }) => {
232
+ const { token } = params;
233
+
234
+ // Rate limit by IP
235
+ const ip = getClientIp(request);
236
+ const rl = await checkRateLimit(ip);
237
+ if (!rl.allowed) {
238
+ set.status = 429;
239
+ return { error: "Too many requests", retryAfter: rl.retryAfter };
240
+ }
241
+
242
+ // Find share link by token
243
+ const [link] = await db
244
+ .select()
245
+ .from(shareLinks)
246
+ .where(eq(shareLinks.token, token))
247
+ .limit(1);
248
+
249
+ if (!link) {
250
+ set.status = 404;
251
+ return { error: "Share link not found" };
252
+ }
253
+
254
+ // Check if expired — return 410 Gone
255
+ if (link.expiresAt && link.expiresAt < new Date()) {
256
+ set.status = 410;
257
+ return { error: "Share link has expired" };
258
+ }
259
+
260
+ // Check password if required
261
+ if (link.passwordHash) {
262
+ const password = request.headers.get("x-share-password");
263
+ if (!password) {
264
+ set.status = 401;
265
+ return { error: "Password required", requiresPassword: true };
266
+ }
267
+ const valid = await Bun.password.verify(password, link.passwordHash);
268
+ if (!valid) {
269
+ set.status = 401;
270
+ return { error: "Invalid password" };
271
+ }
272
+ }
273
+
274
+ // Return document content
275
+ if (link.documentId) {
276
+ const [doc] = await db
277
+ .select({
278
+ id: documents.id,
279
+ title: documents.title,
280
+ content: documents.content,
281
+ contentJson: documents.contentJson,
282
+ metadata: documents.metadata,
283
+ createdAt: documents.createdAt,
284
+ updatedAt: documents.updatedAt,
285
+ })
286
+ .from(documents)
287
+ .where(eq(documents.id, link.documentId))
288
+ .limit(1);
289
+
290
+ if (!doc) {
291
+ set.status = 404;
292
+ return { error: "Shared document no longer exists" };
293
+ }
294
+
295
+ return {
296
+ type: "document" as const,
297
+ data: {
298
+ id: doc.id,
299
+ title: doc.title,
300
+ content: doc.content,
301
+ contentJson: doc.contentJson,
302
+ metadata: doc.metadata,
303
+ createdAt: doc.createdAt.toISOString(),
304
+ updatedAt: doc.updatedAt.toISOString(),
305
+ },
306
+ };
307
+ }
308
+
309
+ // Return folder content
310
+ if (link.folderId) {
311
+ const [folder] = await db
312
+ .select({
313
+ id: folders.id,
314
+ name: folders.name,
315
+ createdAt: folders.createdAt,
316
+ updatedAt: folders.updatedAt,
317
+ })
318
+ .from(folders)
319
+ .where(eq(folders.id, link.folderId))
320
+ .limit(1);
321
+
322
+ if (!folder) {
323
+ set.status = 404;
324
+ return { error: "Shared folder no longer exists" };
325
+ }
326
+
327
+ const folderDocs = await db
328
+ .select({
329
+ id: documents.id,
330
+ title: documents.title,
331
+ createdAt: documents.createdAt,
332
+ updatedAt: documents.updatedAt,
333
+ })
334
+ .from(documents)
335
+ .where(eq(documents.folderId, link.folderId))
336
+ .orderBy(documents.title);
337
+
338
+ return {
339
+ type: "folder" as const,
340
+ data: {
341
+ id: folder.id,
342
+ name: folder.name,
343
+ createdAt: folder.createdAt.toISOString(),
344
+ updatedAt: folder.updatedAt.toISOString(),
345
+ documents: folderDocs.map((doc) => ({
346
+ id: doc.id,
347
+ title: doc.title,
348
+ createdAt: doc.createdAt.toISOString(),
349
+ updatedAt: doc.updatedAt.toISOString(),
350
+ })),
351
+ },
352
+ };
353
+ }
354
+
355
+ set.status = 500;
356
+ return { error: "Share link has no associated content" };
357
+ })
358
+
359
+ // DELETE /api/share/:id — Revoke share link (auth required, owner only)
360
+ .delete("/:id", async ({ params, request, set }) => {
361
+ const userId = await getSessionUserId(request.headers);
362
+ if (!userId) {
363
+ set.status = 401;
364
+ return { error: "Unauthorized" };
365
+ }
366
+
367
+ const { id } = params;
368
+
369
+ const [link] = await db
370
+ .select({ id: shareLinks.id, createdBy: shareLinks.createdBy })
371
+ .from(shareLinks)
372
+ .where(eq(shareLinks.id, id))
373
+ .limit(1);
374
+
375
+ if (!link) {
376
+ set.status = 404;
377
+ return { error: "Share link not found" };
378
+ }
379
+
380
+ if (link.createdBy !== userId) {
381
+ set.status = 403;
382
+ return { error: "Forbidden: you can only revoke your own share links" };
383
+ }
384
+
385
+ // guest_access rows cascade via FK
386
+ await db.delete(shareLinks).where(eq(shareLinks.id, id));
387
+
388
+ logger.info({ shareId: id, userId }, "Share link revoked");
389
+
390
+ return { success: true };
391
+ })
392
+
393
+ // POST /api/share/:id/guests — Add guest email access (auth required)
394
+ .post("/:id/guests", async ({ params, request, set }) => {
395
+ const userId = await getSessionUserId(request.headers);
396
+ if (!userId) {
397
+ set.status = 401;
398
+ return { error: "Unauthorized" };
399
+ }
400
+
401
+ const { id } = params;
402
+
403
+ // Verify ownership
404
+ const [link] = await db
405
+ .select({ id: shareLinks.id, createdBy: shareLinks.createdBy })
406
+ .from(shareLinks)
407
+ .where(eq(shareLinks.id, id))
408
+ .limit(1);
409
+
410
+ if (!link) {
411
+ set.status = 404;
412
+ return { error: "Share link not found" };
413
+ }
414
+
415
+ if (link.createdBy !== userId) {
416
+ set.status = 403;
417
+ return {
418
+ error: "Forbidden: you can only add guests to your own share links",
419
+ };
420
+ }
421
+
422
+ let body: unknown;
423
+ try {
424
+ body = await request.json();
425
+ } catch {
426
+ set.status = 400;
427
+ return { error: "Invalid JSON body" };
428
+ }
429
+
430
+ const parsed = addGuestSchema.safeParse(body);
431
+ if (!parsed.success) {
432
+ set.status = 400;
433
+ return {
434
+ error: "Validation failed",
435
+ details: parsed.error.flatten().fieldErrors,
436
+ };
437
+ }
438
+
439
+ const rows = await db
440
+ .insert(guestAccess)
441
+ .values({ shareLinkId: id, guestEmail: parsed.data.email })
442
+ .onConflictDoNothing()
443
+ .returning();
444
+
445
+ const guest = rows[0];
446
+ if (!guest) {
447
+ return { success: true, message: "Guest already has access" };
448
+ }
449
+
450
+ logger.info(
451
+ { shareId: id, guestEmail: parsed.data.email, userId },
452
+ "Guest access granted",
453
+ );
454
+
455
+ return {
456
+ success: true,
457
+ guest: {
458
+ id: guest.id,
459
+ email: guest.guestEmail,
460
+ grantedAt: guest.grantedAt.toISOString(),
461
+ },
462
+ };
463
+ })
464
+
465
+ // DELETE /api/share/:id/guests/:email — Remove guest access (auth required)
466
+ .delete("/:id/guests/:email", async ({ params, request, set }) => {
467
+ const userId = await getSessionUserId(request.headers);
468
+ if (!userId) {
469
+ set.status = 401;
470
+ return { error: "Unauthorized" };
471
+ }
472
+
473
+ const { id, email } = params;
474
+
475
+ // Verify ownership
476
+ const [link] = await db
477
+ .select({ id: shareLinks.id, createdBy: shareLinks.createdBy })
478
+ .from(shareLinks)
479
+ .where(eq(shareLinks.id, id))
480
+ .limit(1);
481
+
482
+ if (!link) {
483
+ set.status = 404;
484
+ return { error: "Share link not found" };
485
+ }
486
+
487
+ if (link.createdBy !== userId) {
488
+ set.status = 403;
489
+ return {
490
+ error: "Forbidden: you can only manage guests on your own share links",
491
+ };
492
+ }
493
+
494
+ const deleted = await db
495
+ .delete(guestAccess)
496
+ .where(
497
+ and(eq(guestAccess.shareLinkId, id), eq(guestAccess.guestEmail, email)),
498
+ )
499
+ .returning();
500
+
501
+ if (deleted.length === 0) {
502
+ set.status = 404;
503
+ return { error: "Guest not found" };
504
+ }
505
+
506
+ logger.info(
507
+ { shareId: id, guestEmail: email, userId },
508
+ "Guest access revoked",
509
+ );
510
+
511
+ return { success: true };
512
+ });