@takosjp/yurucommu-core 3.0.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 (185) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +82 -0
  3. package/migrations/0001_init.sql +495 -0
  4. package/migrations/0002_social_remote_actor_edges.sql +92 -0
  5. package/migrations/0003_activity_remote_object_edges.sql +68 -0
  6. package/migrations/0004_blocklist.sql +26 -0
  7. package/migrations/0005_story_community_scope.sql +13 -0
  8. package/migrations/0006_dm_community_read_status.sql +19 -0
  9. package/migrations/0007_moderation_reports.sql +22 -0
  10. package/migrations/0008_actor_fields_aka.sql +18 -0
  11. package/migrations/0009_object_tags.sql +13 -0
  12. package/migrations/0010_object_recipients_drop_actor_fk.sql +34 -0
  13. package/migrations/0011_drop_remote_actor_fks.sql +205 -0
  14. package/migrations/0012_objects_content_fts.sql +39 -0
  15. package/migrations/0013_efficiency_indexes.sql +13 -0
  16. package/migrations/0014_inbox_actor_created_idx.sql +15 -0
  17. package/migrations/0015_community_bans.sql +16 -0
  18. package/migrations/0016_namespace_takos_oidc_subject.sql +19 -0
  19. package/migrations/0017_mobile_push_registrations.sql +22 -0
  20. package/migrations/README.md +122 -0
  21. package/package.json +75 -0
  22. package/packages/api/LICENSE +16 -0
  23. package/packages/api/package.json +30 -0
  24. package/packages/api/src/index.ts +4 -0
  25. package/packages/api/src/lib/api/account.ts +20 -0
  26. package/packages/api/src/lib/api/actors.ts +149 -0
  27. package/packages/api/src/lib/api/auth.ts +46 -0
  28. package/packages/api/src/lib/api/communities.ts +329 -0
  29. package/packages/api/src/lib/api/dm.test.ts +67 -0
  30. package/packages/api/src/lib/api/dm.ts +236 -0
  31. package/packages/api/src/lib/api/fetch.ts +111 -0
  32. package/packages/api/src/lib/api/follow.ts +30 -0
  33. package/packages/api/src/lib/api/media.ts +100 -0
  34. package/packages/api/src/lib/api/moderation.ts +98 -0
  35. package/packages/api/src/lib/api/normalize.ts +71 -0
  36. package/packages/api/src/lib/api/notifications.test.ts +63 -0
  37. package/packages/api/src/lib/api/notifications.ts +61 -0
  38. package/packages/api/src/lib/api/posts.test.ts +110 -0
  39. package/packages/api/src/lib/api/posts.ts +181 -0
  40. package/packages/api/src/lib/api/recommendations.ts +22 -0
  41. package/packages/api/src/lib/api/search.ts +88 -0
  42. package/packages/api/src/lib/api/stories.ts +80 -0
  43. package/packages/api/src/lib/api.ts +15 -0
  44. package/packages/api/src/lib/fetch-with-timeout.ts +42 -0
  45. package/packages/api/src/lib/transport.ts +40 -0
  46. package/packages/api/src/social-server.ts +47 -0
  47. package/packages/api/src/types/index.ts +185 -0
  48. package/scripts/apply-takosumi-migrations.ts +621 -0
  49. package/src/backend/federation-helpers.ts +36 -0
  50. package/src/backend/index.ts +872 -0
  51. package/src/backend/lib/account-migration.ts +106 -0
  52. package/src/backend/lib/activitypub-actor-cache.ts +238 -0
  53. package/src/backend/lib/activitypub-helpers.ts +131 -0
  54. package/src/backend/lib/activitypub-validators.ts +323 -0
  55. package/src/backend/lib/ap-context.ts +16 -0
  56. package/src/backend/lib/ap-ids.ts +101 -0
  57. package/src/backend/lib/ap-response.ts +30 -0
  58. package/src/backend/lib/ap-signing.ts +87 -0
  59. package/src/backend/lib/ap-verify.ts +670 -0
  60. package/src/backend/lib/auth-lockout.ts +230 -0
  61. package/src/backend/lib/backend-paths.ts +34 -0
  62. package/src/backend/lib/base64.ts +30 -0
  63. package/src/backend/lib/blocklist-purge.ts +109 -0
  64. package/src/backend/lib/blocklist.ts +279 -0
  65. package/src/backend/lib/chunk.ts +33 -0
  66. package/src/backend/lib/client-ip.ts +169 -0
  67. package/src/backend/lib/community-visibility.ts +230 -0
  68. package/src/backend/lib/crypto.ts +424 -0
  69. package/src/backend/lib/delivery/circuit.ts +265 -0
  70. package/src/backend/lib/delivery/metrics.ts +30 -0
  71. package/src/backend/lib/delivery/planner.ts +190 -0
  72. package/src/backend/lib/delivery/queue-batching.ts +626 -0
  73. package/src/backend/lib/delivery/queue-delivery.ts +641 -0
  74. package/src/backend/lib/delivery/queue.ts +576 -0
  75. package/src/backend/lib/delivery/transformers.ts +56 -0
  76. package/src/backend/lib/delivery/types.ts +139 -0
  77. package/src/backend/lib/errors.ts +114 -0
  78. package/src/backend/lib/federation-fetch.ts +296 -0
  79. package/src/backend/lib/feed-cursor.ts +57 -0
  80. package/src/backend/lib/feed-exclude.ts +48 -0
  81. package/src/backend/lib/hex.ts +8 -0
  82. package/src/backend/lib/log-mask.ts +213 -0
  83. package/src/backend/lib/logger.ts +285 -0
  84. package/src/backend/lib/mobile-contract.ts +137 -0
  85. package/src/backend/lib/oauth-providers.ts +324 -0
  86. package/src/backend/lib/oauth-utils.ts +148 -0
  87. package/src/backend/lib/oidc-id-token.ts +151 -0
  88. package/src/backend/lib/parse-helpers.ts +31 -0
  89. package/src/backend/lib/post-visibility.ts +190 -0
  90. package/src/backend/lib/session-actor.ts +61 -0
  91. package/src/backend/lib/ssrf.ts +428 -0
  92. package/src/backend/lib/strip-image-metadata.ts +191 -0
  93. package/src/backend/middleware/bearer-auth.ts +70 -0
  94. package/src/backend/middleware/body-limit.ts +212 -0
  95. package/src/backend/middleware/cache.ts +429 -0
  96. package/src/backend/middleware/csrf.ts +130 -0
  97. package/src/backend/middleware/error-handler.ts +77 -0
  98. package/src/backend/middleware/rate-limit.ts +308 -0
  99. package/src/backend/public.ts +21 -0
  100. package/src/backend/routes/account-teardown.ts +430 -0
  101. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +354 -0
  102. package/src/backend/routes/activitypub/handlers/inbound-timestamp.ts +29 -0
  103. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1634 -0
  104. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +547 -0
  105. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +497 -0
  106. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +262 -0
  107. package/src/backend/routes/activitypub/handlers/user-inbox-handlers.ts +35 -0
  108. package/src/backend/routes/activitypub/inbox-types.ts +74 -0
  109. package/src/backend/routes/activitypub/inbox.ts +1191 -0
  110. package/src/backend/routes/activitypub/outbox.ts +0 -0
  111. package/src/backend/routes/activitypub/query-helpers.ts +227 -0
  112. package/src/backend/routes/activitypub.ts +616 -0
  113. package/src/backend/routes/actors-helpers.ts +487 -0
  114. package/src/backend/routes/actors.ts +1311 -0
  115. package/src/backend/routes/apps.ts +313 -0
  116. package/src/backend/routes/auth-helpers.ts +566 -0
  117. package/src/backend/routes/auth.ts +615 -0
  118. package/src/backend/routes/communities/membership-invites.ts +208 -0
  119. package/src/backend/routes/communities/membership-join.ts +335 -0
  120. package/src/backend/routes/communities/membership-members.ts +539 -0
  121. package/src/backend/routes/communities/membership-requests.ts +296 -0
  122. package/src/backend/routes/communities/membership-shared.ts +364 -0
  123. package/src/backend/routes/communities/messages.ts +479 -0
  124. package/src/backend/routes/communities/routes.ts +624 -0
  125. package/src/backend/routes/communities.ts +21 -0
  126. package/src/backend/routes/dm/contacts.ts +525 -0
  127. package/src/backend/routes/dm/conversations-helpers.ts +197 -0
  128. package/src/backend/routes/dm/conversations.ts +25 -0
  129. package/src/backend/routes/dm/messages.ts +658 -0
  130. package/src/backend/routes/dm/query-helpers.ts +85 -0
  131. package/src/backend/routes/dm/read-archive.ts +228 -0
  132. package/src/backend/routes/dm/requests.ts +222 -0
  133. package/src/backend/routes/dm/typing.ts +81 -0
  134. package/src/backend/routes/dm.ts +15 -0
  135. package/src/backend/routes/follow-helpers.ts +370 -0
  136. package/src/backend/routes/follow.ts +588 -0
  137. package/src/backend/routes/media.ts +692 -0
  138. package/src/backend/routes/mobile.ts +159 -0
  139. package/src/backend/routes/moderation.ts +373 -0
  140. package/src/backend/routes/notifications.ts +757 -0
  141. package/src/backend/routes/posts/delete-cascade.ts +330 -0
  142. package/src/backend/routes/posts/interactions.ts +795 -0
  143. package/src/backend/routes/posts/post-helpers.ts +847 -0
  144. package/src/backend/routes/posts/queries.ts +537 -0
  145. package/src/backend/routes/posts/routes.ts +865 -0
  146. package/src/backend/routes/posts/transformers.ts +161 -0
  147. package/src/backend/routes/posts.ts +17 -0
  148. package/src/backend/routes/recommendations.ts +88 -0
  149. package/src/backend/routes/search.ts +730 -0
  150. package/src/backend/routes/stories/interactions.ts +576 -0
  151. package/src/backend/routes/stories/query-helpers.ts +482 -0
  152. package/src/backend/routes/stories/routes.ts +906 -0
  153. package/src/backend/routes/stories.ts +13 -0
  154. package/src/backend/routes/takos-tools/dm.ts +249 -0
  155. package/src/backend/routes/takos-tools/follows.ts +225 -0
  156. package/src/backend/routes/takos-tools/posts.ts +292 -0
  157. package/src/backend/routes/takos-tools/search.ts +228 -0
  158. package/src/backend/routes/takos-tools/timeline.ts +132 -0
  159. package/src/backend/routes/takos-tools/types.ts +10 -0
  160. package/src/backend/routes/takos-tools-response.ts +178 -0
  161. package/src/backend/routes/takos-tools.ts +153 -0
  162. package/src/backend/routes/timeline.ts +755 -0
  163. package/src/backend/runtime/bun.ts +620 -0
  164. package/src/backend/runtime/cloudflare.ts +202 -0
  165. package/src/backend/runtime/compat-bun/types.ts +44 -0
  166. package/src/backend/runtime/memory-kv.ts +104 -0
  167. package/src/backend/runtime/shared.ts +142 -0
  168. package/src/backend/runtime/types.ts +205 -0
  169. package/src/backend/server.ts +636 -0
  170. package/src/backend/types.ts +143 -0
  171. package/src/db/index.ts +97 -0
  172. package/src/db/schema/actors.ts +129 -0
  173. package/src/db/schema/communities.ts +133 -0
  174. package/src/db/schema/date-utils.ts +17 -0
  175. package/src/db/schema/index.ts +17 -0
  176. package/src/db/schema/messaging.ts +241 -0
  177. package/src/db/schema/mobile.ts +37 -0
  178. package/src/db/schema/posts.ts +150 -0
  179. package/src/db/schema/relations.ts +266 -0
  180. package/src/db/schema/reports.ts +33 -0
  181. package/src/db/schema/social.ts +106 -0
  182. package/src/db/schema/stories.ts +70 -0
  183. package/src/db/schema.ts +15 -0
  184. package/src/plugin/public.ts +7 -0
  185. package/src/runtime/site-worker.ts +10 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Fetch wrapper with consistent configuration.
3
+ * All API calls should use this wrapper so frontend plugins can override
4
+ * transport behavior (URL resolution, auth headers, credentials mode).
5
+ */
6
+
7
+ import {
8
+ fetchWithTimeout,
9
+ type FetchWithTimeoutInit,
10
+ } from "../fetch-with-timeout.ts";
11
+ import { getYurucommuApiTransport } from "../transport.ts";
12
+
13
+ /**
14
+ * Custom error class for API responses that includes the HTTP status code.
15
+ */
16
+ export class ApiError extends Error {
17
+ constructor(
18
+ public readonly status: number,
19
+ message: string,
20
+ ) {
21
+ // `message` is the human-facing server error (or a caller fallback); the
22
+ // HTTP status lives on `.status`. Keep `.message` clean — many UI surfaces
23
+ // render `err.message` verbatim in an error box, and a "<status>: " prefix
24
+ // reads as technical noise (e.g. "422: Add this account as an alias…").
25
+ super(message);
26
+ this.name = "ApiError";
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Read an error message from a failed API response. Attempts to parse JSON
32
+ * with an `error` field; falls back to `statusText`.
33
+ */
34
+ export async function extractErrorMessage(
35
+ res: Response,
36
+ fallback: string,
37
+ ): Promise<string> {
38
+ try {
39
+ const data = (await res.json()) as {
40
+ error?: string | { message?: string };
41
+ };
42
+ // The server uses a flat `{ error: "message" }` envelope. Defensively also
43
+ // unwrap a nested `{ error: { message } }` so a stray non-flat error never
44
+ // renders as the "[object Object]" stringification.
45
+ const err = data.error;
46
+ const message = typeof err === "string" ? err : err?.message;
47
+ return message || fallback;
48
+ } catch {
49
+ return res.statusText || fallback;
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Assert that a response is OK. Throws an `ApiError` with the status code
55
+ * and a message extracted from the response body when it is not.
56
+ */
57
+ export async function assertOk(res: Response, fallback: string): Promise<void> {
58
+ if (!res.ok) {
59
+ const message = await extractErrorMessage(res, fallback);
60
+ throw new ApiError(res.status, message);
61
+ }
62
+ }
63
+
64
+ export interface ApiRequestInit extends FetchWithTimeoutInit {}
65
+
66
+ export function apiFetch(
67
+ url: string,
68
+ options: ApiRequestInit = {},
69
+ ): Promise<Response> {
70
+ const transport = getYurucommuApiTransport();
71
+ const apiUrl = transport.resolveUrl(url);
72
+ const headers = new Headers(options.headers);
73
+
74
+ const authHeaders = transport.getAuthHeaders(url);
75
+ for (const [key, value] of Object.entries(authHeaders)) {
76
+ if (!headers.has(key)) {
77
+ headers.set(key, value);
78
+ }
79
+ }
80
+
81
+ return fetchWithTimeout(apiUrl, {
82
+ ...options,
83
+ headers,
84
+ credentials: options.credentials ?? transport.credentials,
85
+ });
86
+ }
87
+
88
+ function createApiMethod(method: string) {
89
+ return async (
90
+ url: string,
91
+ body?: unknown,
92
+ options: Omit<ApiRequestInit, "method" | "body"> = {},
93
+ ): Promise<Response> => {
94
+ const headers = new Headers(options.headers);
95
+ if (body) {
96
+ headers.set("Content-Type", "application/json");
97
+ }
98
+
99
+ return await apiFetch(url, {
100
+ method,
101
+ headers,
102
+ body: body ? JSON.stringify(body) : undefined,
103
+ ...options,
104
+ });
105
+ };
106
+ }
107
+
108
+ export const apiPost = createApiMethod("POST");
109
+ export const apiPut = createApiMethod("PUT");
110
+ export const apiPatch = createApiMethod("PATCH");
111
+ export const apiDelete = createApiMethod("DELETE");
@@ -0,0 +1,30 @@
1
+ import { apiDelete, apiPost, assertOk } from "./fetch.ts";
2
+
3
+ export async function follow(targetApId: string): Promise<{ status: string }> {
4
+ const res = await apiPost("/api/follow", { target_ap_id: targetApId });
5
+ await assertOk(res, "Failed to follow");
6
+ return (await res.json()) as { status: string };
7
+ }
8
+
9
+ export async function unfollow(targetApId: string): Promise<void> {
10
+ const res = await apiDelete("/api/follow", { target_ap_id: targetApId });
11
+ await assertOk(res, "Failed to unfollow");
12
+ }
13
+
14
+ export async function acceptFollowRequest(
15
+ requesterApId: string,
16
+ ): Promise<void> {
17
+ const res = await apiPost("/api/follow/accept", {
18
+ requester_ap_id: requesterApId,
19
+ });
20
+ await assertOk(res, "Failed to accept");
21
+ }
22
+
23
+ export async function rejectFollowRequest(
24
+ requesterApId: string,
25
+ ): Promise<void> {
26
+ const res = await apiPost("/api/follow/reject", {
27
+ requester_ap_id: requesterApId,
28
+ });
29
+ await assertOk(res, "Failed to reject");
30
+ }
@@ -0,0 +1,100 @@
1
+ import { apiFetch, assertOk } from "./fetch.ts";
2
+ import { UPLOAD_REQUEST_TIMEOUT_MS } from "../fetch-with-timeout.ts";
3
+
4
+ // Allowed MIME types for media uploads
5
+ export const allowedMimeTypes = [
6
+ // Images
7
+ "image/jpeg",
8
+ "image/png",
9
+ "image/gif",
10
+ "image/webp",
11
+ // Videos
12
+ "video/mp4",
13
+ "video/webm",
14
+ ] as const;
15
+
16
+ export type AllowedMimeType = (typeof allowedMimeTypes)[number];
17
+
18
+ // Maximum file sizes MUST match the backend media route (MAX_IMAGE_SIZE /
19
+ // MAX_VIDEO_SIZE in src/backend/routes/media.ts). When the client video cap was
20
+ // 100MB but the server cap was 40MB (and the pre-route body limit 48 MiB), a
21
+ // 48–100MB video passed client validation and was then rejected by the body
22
+ // limit with an opaque "body_too_large" error instead of a friendly up-front
23
+ // message. Keep these in lockstep with the backend.
24
+ export const maxImageFileSize = 20 * 1024 * 1024;
25
+ export const maxVideoFileSize = 40 * 1024 * 1024;
26
+
27
+ // Filename validation regex: alphanumeric, dots, hyphens, underscores
28
+ const filenameRegex = /^[\w\-. ]+$/;
29
+
30
+ export class FileValidationError extends Error {
31
+ constructor(
32
+ message: string,
33
+ public code: "INVALID_TYPE" | "FILE_TOO_LARGE" | "INVALID_FILENAME",
34
+ ) {
35
+ super(message);
36
+ this.name = "FileValidationError";
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Validate a file before upload
42
+ * @throws FileValidationError if validation fails
43
+ */
44
+ export function validateFile(file: File): void {
45
+ // Check file type
46
+ if (!allowedMimeTypes.includes(file.type as AllowedMimeType)) {
47
+ throw new FileValidationError(
48
+ `Invalid file type: ${file.type}. Allowed types: ${allowedMimeTypes.join(
49
+ ", ",
50
+ )}`,
51
+ "INVALID_TYPE",
52
+ );
53
+ }
54
+
55
+ // Check file size
56
+ const maxFileSize = file.type.startsWith("video/")
57
+ ? maxVideoFileSize
58
+ : maxImageFileSize;
59
+ if (file.size > maxFileSize) {
60
+ const sizeMB = (file.size / (1024 * 1024)).toFixed(2);
61
+ const maxMB = maxFileSize / (1024 * 1024);
62
+ throw new FileValidationError(
63
+ `File too large: ${sizeMB}MB. Maximum size: ${maxMB}MB`,
64
+ "FILE_TOO_LARGE",
65
+ );
66
+ }
67
+
68
+ // Check filename
69
+ if (!filenameRegex.test(file.name)) {
70
+ throw new FileValidationError(
71
+ `Invalid filename: ${file.name}. Filename can only contain letters, numbers, dots, hyphens, underscores, and spaces.`,
72
+ "INVALID_FILENAME",
73
+ );
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Upload a media file
79
+ * @throws FileValidationError if validation fails
80
+ * @throws Error if upload fails
81
+ */
82
+ export async function uploadMedia(
83
+ file: File,
84
+ ): Promise<{ url: string; r2_key: string; content_type: string }> {
85
+ // Validate file before upload
86
+ validateFile(file);
87
+
88
+ const formData = new FormData();
89
+ formData.append("file", file);
90
+
91
+ const res = await apiFetch("/api/media/upload", {
92
+ method: "POST",
93
+ body: formData,
94
+ timeoutMs: UPLOAD_REQUEST_TIMEOUT_MS,
95
+ });
96
+
97
+ await assertOk(res, "Failed to upload");
98
+
99
+ return res.json();
100
+ }
@@ -0,0 +1,98 @@
1
+ import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
2
+
3
+ // Owner-only federation moderation surface. Mirrors the backend operator
4
+ // routes mounted at /api/moderation (see src/backend/routes/moderation.ts).
5
+ // Every call is gated server-side to the instance owner (role === "owner")
6
+ // and returns 403 otherwise; the UI also hides the entry from non-owners.
7
+
8
+ export interface BlockedDomain {
9
+ domain: string;
10
+ reason: string | null;
11
+ created_at: string;
12
+ }
13
+
14
+ export interface BlockedActor {
15
+ actor_ap_id: string;
16
+ reason: string | null;
17
+ created_at: string;
18
+ }
19
+
20
+ export interface ModerationReport {
21
+ id: string;
22
+ reporter_ap_id: string | null;
23
+ target_ap_id: string | null;
24
+ content: string | null;
25
+ instance: string | null;
26
+ created_at: string;
27
+ resolved_at: string | null;
28
+ }
29
+
30
+ export async function fetchBlockedDomains(): Promise<BlockedDomain[]> {
31
+ const res = await apiFetch("/api/moderation/domains");
32
+ await assertOk(res, "Failed to fetch blocked domains");
33
+ const data = (await res.json()) as { domains?: BlockedDomain[] };
34
+ return data.domains || [];
35
+ }
36
+
37
+ export async function blockDomain(
38
+ domain: string,
39
+ reason?: string,
40
+ ): Promise<void> {
41
+ const res = await apiPost("/api/moderation/domains", { domain, reason });
42
+ await assertOk(res, "Failed to block domain");
43
+ }
44
+
45
+ export async function unblockDomain(domain: string): Promise<void> {
46
+ const res = await apiDelete("/api/moderation/domains", { domain });
47
+ await assertOk(res, "Failed to unblock domain");
48
+ }
49
+
50
+ export async function fetchBlockedActors(): Promise<BlockedActor[]> {
51
+ const res = await apiFetch("/api/moderation/actors");
52
+ await assertOk(res, "Failed to fetch blocked actors");
53
+ const data = (await res.json()) as { actors?: BlockedActor[] };
54
+ return data.actors || [];
55
+ }
56
+
57
+ export async function blockActor(apId: string, reason?: string): Promise<void> {
58
+ const res = await apiPost("/api/moderation/actors", { ap_id: apId, reason });
59
+ await assertOk(res, "Failed to block actor");
60
+ }
61
+
62
+ export async function unblockActor(apId: string): Promise<void> {
63
+ const res = await apiDelete("/api/moderation/actors", { ap_id: apId });
64
+ await assertOk(res, "Failed to unblock actor");
65
+ }
66
+
67
+ export async function fetchReports(options?: {
68
+ onlyOpen?: boolean;
69
+ }): Promise<ModerationReport[]> {
70
+ const query = options?.onlyOpen ? "?status=open" : "";
71
+ const res = await apiFetch(`/api/moderation/reports${query}`);
72
+ await assertOk(res, "Failed to fetch reports");
73
+ const data = (await res.json()) as { reports?: ModerationReport[] };
74
+ return data.reports || [];
75
+ }
76
+
77
+ export async function resolveReport(id: string, reopen = false): Promise<void> {
78
+ const res = await apiPost(
79
+ `/api/moderation/reports/${encodeURIComponent(id)}/resolve`,
80
+ { reopen },
81
+ );
82
+ await assertOk(res, "Failed to resolve report");
83
+ }
84
+
85
+ // File an outbound abuse Flag against a remote actor (and optionally one of
86
+ // their posts), federated to their instance from this instance's actor.
87
+ export async function reportContent(input: {
88
+ targetActorApId: string;
89
+ postApId?: string;
90
+ reason?: string;
91
+ }): Promise<void> {
92
+ const res = await apiPost("/api/moderation/reports/outbound", {
93
+ target_actor_ap_id: input.targetActorApId,
94
+ post_ap_id: input.postApId,
95
+ reason: input.reason,
96
+ });
97
+ await assertOk(res, "Failed to submit report");
98
+ }
@@ -0,0 +1,71 @@
1
+ import {
2
+ Actor,
3
+ ActorStories,
4
+ Notification,
5
+ Post,
6
+ Story,
7
+ } from "../../types/index.ts";
8
+
9
+ type ActorLike = {
10
+ ap_id: string;
11
+ username?: string;
12
+ preferred_username?: string;
13
+ };
14
+
15
+ function formatUsernameFromApId(
16
+ apId: string,
17
+ preferred?: string,
18
+ ): string | null {
19
+ try {
20
+ const url = new URL(apId);
21
+ const match = apId.match(/\/(users|groups)\/([^/]+)$/);
22
+ if (match) return `${match[2]}@${url.host}`;
23
+ if (preferred) return `${preferred}@${url.host}`;
24
+ } catch {
25
+ // Ignore malformed URLs and fallback to existing fields.
26
+ }
27
+ return null;
28
+ }
29
+
30
+ export function normalizeActor<T extends ActorLike>(actor: T): T {
31
+ if (!actor || !actor.ap_id) return actor;
32
+ const rawUsername = actor.username?.trim();
33
+ const formatted =
34
+ rawUsername ||
35
+ formatUsernameFromApId(actor.ap_id, actor.preferred_username) ||
36
+ actor.preferred_username ||
37
+ actor.username ||
38
+ actor.ap_id;
39
+ const preferred =
40
+ actor.preferred_username?.trim() ||
41
+ (formatted.includes("@") ? formatted.split("@")[0] : formatted);
42
+
43
+ return {
44
+ ...actor,
45
+ username: formatted,
46
+ preferred_username: preferred,
47
+ };
48
+ }
49
+
50
+ export const normalizePost = (post: Post): Post => ({
51
+ ...post,
52
+ author: normalizeActor(post.author),
53
+ });
54
+
55
+ export const normalizeStory = (story: Story): Story => ({
56
+ ...story,
57
+ author: normalizeActor(story.author),
58
+ });
59
+
60
+ export const normalizeActorStories = (stories: ActorStories): ActorStories => ({
61
+ ...stories,
62
+ actor: normalizeActor(stories.actor),
63
+ stories: (stories.stories || []).map(normalizeStory),
64
+ });
65
+
66
+ export const normalizeNotification = (
67
+ notification: Notification,
68
+ ): Notification => ({
69
+ ...notification,
70
+ actor: normalizeActor(notification.actor),
71
+ });
@@ -0,0 +1,63 @@
1
+ import { expect, test } from "bun:test";
2
+ import { clearYurucommuApiTransport } from "../transport.ts";
3
+ import type { Notification } from "../../types/index.ts";
4
+ import { fetchNotifications } from "./notifications.ts";
5
+
6
+ function makeNotification(id: string): Notification {
7
+ return {
8
+ id,
9
+ type: "like",
10
+ actor: {
11
+ ap_id: "https://example.com/ap/users/alice",
12
+ username: "alice@example.com",
13
+ preferred_username: "alice",
14
+ name: "Alice",
15
+ icon_url: null,
16
+ },
17
+ object_ap_id: null,
18
+ read: false,
19
+ created_at: "2026-01-01T00:00:00.000Z",
20
+ } as unknown as Notification;
21
+ }
22
+
23
+ async function withMockFetch<T>(
24
+ responseBody: unknown,
25
+ fn: () => Promise<T>,
26
+ ): Promise<T> {
27
+ const originalFetch = globalThis.fetch;
28
+ clearYurucommuApiTransport();
29
+ globalThis.fetch = ((_input: RequestInfo | URL) =>
30
+ Promise.resolve(
31
+ new Response(JSON.stringify(responseBody), {
32
+ status: 200,
33
+ headers: { "Content-Type": "application/json" },
34
+ }),
35
+ )) as typeof fetch;
36
+ try {
37
+ return await fn();
38
+ } finally {
39
+ globalThis.fetch = originalFetch;
40
+ clearYurucommuApiTransport();
41
+ }
42
+ }
43
+
44
+ // Regression: the notifications "load older" affordance depends on the client
45
+ // surfacing the server's `has_more` (it was previously discarded).
46
+ test("fetchNotifications surfaces has_more as hasMore and maps notifications", async () => {
47
+ const result = await withMockFetch(
48
+ { notifications: [makeNotification("n1")], has_more: true },
49
+ () => fetchNotifications({ limit: 20 }),
50
+ );
51
+
52
+ expect(result.notifications.map((n) => n.id)).toEqual(["n1"]);
53
+ expect(result.hasMore).toBe(true);
54
+ });
55
+
56
+ test("fetchNotifications defaults hasMore to false when the server omits it", async () => {
57
+ const result = await withMockFetch({ notifications: [] }, () =>
58
+ fetchNotifications({ limit: 20 }),
59
+ );
60
+
61
+ expect(result.notifications).toEqual([]);
62
+ expect(result.hasMore).toBe(false);
63
+ });
@@ -0,0 +1,61 @@
1
+ import type { Notification } from "../../types/index.ts";
2
+ import { normalizeNotification } from "./normalize.ts";
3
+ import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
4
+
5
+ export async function fetchNotifications(options?: {
6
+ limit?: number;
7
+ type?: string;
8
+ before?: string;
9
+ archived?: boolean;
10
+ }): Promise<{
11
+ notifications: Notification[];
12
+ hasMore: boolean;
13
+ nextCursor: string | null;
14
+ }> {
15
+ const params = new URLSearchParams();
16
+ if (options?.limit) params.set("limit", options.limit.toString());
17
+ if (options?.type && options.type !== "all") params.set("type", options.type);
18
+ if (options?.before) params.set("before", options.before);
19
+ if (options?.archived) params.set("archived", "true");
20
+ const query = params.toString() ? `?${params}` : "";
21
+ const res = await apiFetch(`/api/notifications${query}`);
22
+ await assertOk(res, "Failed to load notifications");
23
+ const data = (await res.json()) as {
24
+ notifications?: Notification[];
25
+ has_more?: boolean;
26
+ next_cursor?: string | null;
27
+ };
28
+ return {
29
+ notifications: (data.notifications || []).map(normalizeNotification),
30
+ hasMore: data.has_more ?? false,
31
+ nextCursor: data.next_cursor ?? null,
32
+ };
33
+ }
34
+
35
+ export async function fetchUnreadCount(): Promise<number> {
36
+ const res = await apiFetch("/api/notifications/unread/count");
37
+ const data = (await res.json()) as { count?: number };
38
+ return data.count || 0;
39
+ }
40
+
41
+ export async function markNotificationsRead(ids?: string[]): Promise<void> {
42
+ const res = await apiPost("/api/notifications/read", { ids });
43
+ await assertOk(res, "Failed to mark as read");
44
+ }
45
+
46
+ export async function archiveNotifications(ids: string[]): Promise<void> {
47
+ const res = await apiPost("/api/notifications/archive", { ids });
48
+ await assertOk(res, "Failed to archive");
49
+ }
50
+
51
+ export async function unarchiveNotifications(ids: string[]): Promise<void> {
52
+ const res = await apiDelete("/api/notifications/archive", { ids });
53
+ await assertOk(res, "Failed to unarchive");
54
+ }
55
+
56
+ export async function archiveAllNotifications(): Promise<number> {
57
+ const res = await apiPost("/api/notifications/archive/all", {});
58
+ await assertOk(res, "Failed to archive all");
59
+ const data = (await res.json()) as { archived_count?: number };
60
+ return data.archived_count ?? 0;
61
+ }
@@ -0,0 +1,110 @@
1
+ import { expect, test } from "bun:test";
2
+ import { clearYurucommuApiTransport } from "../transport.ts";
3
+ import type { Post } from "../../types/index.ts";
4
+ import { createPost, fetchBookmarks, fetchTimeline } from "./posts.ts";
5
+
6
+ function makePost(overrides: Partial<Post> = {}): Post {
7
+ return {
8
+ ap_id: "https://example.com/ap/objects/post-1",
9
+ type: "Note",
10
+ author: {
11
+ ap_id: "https://example.com/ap/users/alice",
12
+ username: "alice@example.com",
13
+ preferred_username: "alice",
14
+ name: "Alice",
15
+ icon_url: null,
16
+ },
17
+ content: "hello",
18
+ summary: null,
19
+ attachments: [],
20
+ in_reply_to: null,
21
+ visibility: "public",
22
+ community_ap_id: null,
23
+ like_count: 0,
24
+ reply_count: 0,
25
+ announce_count: 0,
26
+ published: "2026-01-01T00:00:00.000Z",
27
+ edited_at: null,
28
+ liked: false,
29
+ bookmarked: false,
30
+ reposted: false,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ async function withMockFetch<T>(
36
+ responseBody: unknown,
37
+ fn: () => Promise<T>,
38
+ ): Promise<T> {
39
+ const originalFetch = globalThis.fetch;
40
+ clearYurucommuApiTransport();
41
+ globalThis.fetch = ((_input: RequestInfo | URL, _init?: RequestInit) => {
42
+ return Promise.resolve(
43
+ new Response(JSON.stringify(responseBody), {
44
+ status: 200,
45
+ headers: { "Content-Type": "application/json" },
46
+ }),
47
+ );
48
+ }) as typeof fetch;
49
+
50
+ try {
51
+ return await fn();
52
+ } finally {
53
+ globalThis.fetch = originalFetch;
54
+ clearYurucommuApiTransport();
55
+ }
56
+ }
57
+
58
+ test("createPost reads the current wrapped post response", async () => {
59
+ const post = makePost();
60
+
61
+ const result = await withMockFetch({ post }, () =>
62
+ createPost({ content: "hello" }),
63
+ );
64
+
65
+ expect(result.ap_id).toBe(post.ap_id);
66
+ });
67
+
68
+ test("fetchBookmarks reads the posts + pagination fields", async () => {
69
+ const post = makePost({ bookmarked: true });
70
+
71
+ const result = await withMockFetch(
72
+ { posts: [post], has_more: true, next_cursor: "c1" },
73
+ () => fetchBookmarks(),
74
+ );
75
+
76
+ expect(result.posts.map((p) => p.ap_id)).toEqual([post.ap_id]);
77
+ expect(result.hasMore).toBe(true);
78
+ expect(result.nextCursor).toBe("c1");
79
+ });
80
+
81
+ // Regression: the timeline client must SURFACE the server's composite cursor (so
82
+ // loadMore can echo it back as `before`). It was discarding `next_cursor` and
83
+ // paginating with a post's ap_id instead, which the server decodes as a
84
+ // published-only cursor whose string compare matches every row → the feed
85
+ // re-serves page 1 forever and never advances.
86
+ test("fetchTimeline surfaces the server next_cursor and has_more", async () => {
87
+ const post = makePost();
88
+ const result = await withMockFetch(
89
+ {
90
+ posts: [post],
91
+ has_more: true,
92
+ next_cursor: "2026-01-01T00:00:00.000Z\u0000" + post.ap_id,
93
+ },
94
+ () => fetchTimeline({ limit: 20 }),
95
+ );
96
+
97
+ expect(result.posts.map((p) => p.ap_id)).toEqual([post.ap_id]);
98
+ expect(result.hasMore).toBe(true);
99
+ expect(result.nextCursor).toBe("2026-01-01T00:00:00.000Z\u0000" + post.ap_id);
100
+ });
101
+
102
+ test("fetchTimeline defaults to no cursor / hasMore=false when the server omits them", async () => {
103
+ const result = await withMockFetch({ posts: [] }, () =>
104
+ fetchTimeline({ limit: 20 }),
105
+ );
106
+
107
+ expect(result.posts).toEqual([]);
108
+ expect(result.hasMore).toBe(false);
109
+ expect(result.nextCursor).toBe(null);
110
+ });