@takosjp/yurucommu-core 3.0.0 → 3.0.2

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.
@@ -0,0 +1,18 @@
1
+ CREATE TABLE IF NOT EXISTS actor_notes (
2
+ actor_ap_id TEXT PRIMARY KEY,
3
+ content TEXT NOT NULL,
4
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
5
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
6
+ expires_at TEXT NOT NULL,
7
+ deleted_at TEXT,
8
+ FOREIGN KEY (actor_ap_id) REFERENCES actors(ap_id) ON DELETE CASCADE
9
+ );
10
+
11
+ CREATE INDEX IF NOT EXISTS actor_notes_expires_idx
12
+ ON actor_notes(expires_at);
13
+
14
+ CREATE INDEX IF NOT EXISTS actor_notes_updated_idx
15
+ ON actor_notes(updated_at);
16
+
17
+ CREATE INDEX IF NOT EXISTS actor_notes_deleted_idx
18
+ ON actor_notes(deleted_at);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -12,10 +12,30 @@
12
12
  "src/plugin",
13
13
  "src/runtime",
14
14
  "!src/backend/**/__tests__/**",
15
- "!packages/api/src/**/*.test.ts",
16
15
  "scripts/apply-takosumi-migrations.ts",
17
16
  "migrations",
18
- "packages/api/src",
17
+ "packages/api/src/index.ts",
18
+ "packages/api/src/social-server.ts",
19
+ "packages/api/src/types",
20
+ "packages/api/src/lib/api.ts",
21
+ "packages/api/src/lib/transport.ts",
22
+ "packages/api/src/lib/fetch-with-timeout.ts",
23
+ "packages/api/src/lib/api/account.ts",
24
+ "packages/api/src/lib/api/actors.ts",
25
+ "packages/api/src/lib/api/auth.ts",
26
+ "packages/api/src/lib/api/communities.ts",
27
+ "packages/api/src/lib/api/dm.ts",
28
+ "packages/api/src/lib/api/fetch.ts",
29
+ "packages/api/src/lib/api/follow.ts",
30
+ "packages/api/src/lib/api/media.ts",
31
+ "packages/api/src/lib/api/moderation.ts",
32
+ "packages/api/src/lib/api/notes.ts",
33
+ "packages/api/src/lib/api/normalize.ts",
34
+ "packages/api/src/lib/api/notifications.ts",
35
+ "packages/api/src/lib/api/posts.ts",
36
+ "packages/api/src/lib/api/recommendations.ts",
37
+ "packages/api/src/lib/api/search.ts",
38
+ "packages/api/src/lib/api/stories.ts",
19
39
  "packages/api/package.json",
20
40
  "packages/api/LICENSE"
21
41
  ],
@@ -40,6 +60,7 @@
40
60
  "test:backend": "bun test src/backend/",
41
61
  "build": "bun run build:api",
42
62
  "build:api": "cd packages/api && bun run build",
63
+ "prepublishOnly": "bun scripts/check-publish-version-discipline.mjs .",
43
64
  "pack:core": "npm pack --dry-run",
44
65
  "pack:api": "cd packages/api && bun run pack:dry",
45
66
  "app:activate": "bun scripts/apply-takosumi-migrations.ts",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "3.0.0",
3
+ "version": "3.0.1",
4
4
  "description": "Typed client SDK and public API contract for yurucommu-server clients.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "type": "module",
@@ -19,6 +19,7 @@
19
19
  "scripts": {
20
20
  "check": "tsc --noEmit -p tsconfig.json",
21
21
  "build": "rm -rf dist && bun build src/index.ts --target browser --format esm --outfile dist/index.js && bunx tsc -p tsconfig.build.json && bun scripts/rewrite-dts-imports.mjs",
22
+ "prepublishOnly": "bun ../../scripts/check-publish-version-discipline.mjs .",
22
23
  "pack:dry": "npm pack --dry-run"
23
24
  },
24
25
  "devDependencies": {
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Actor,
3
3
  ActorStories,
4
+ ActorNote,
4
5
  Notification,
5
6
  Post,
6
7
  Story,
@@ -63,6 +64,11 @@ export const normalizeActorStories = (stories: ActorStories): ActorStories => ({
63
64
  stories: (stories.stories || []).map(normalizeStory),
64
65
  });
65
66
 
67
+ export const normalizeActorNote = (note: ActorNote): ActorNote => ({
68
+ ...note,
69
+ actor: normalizeActor(note.actor),
70
+ });
71
+
66
72
  export const normalizeNotification = (
67
73
  notification: Notification,
68
74
  ): Notification => ({
@@ -0,0 +1,25 @@
1
+ import type { ActorNote } from "../../types/index.ts";
2
+ import { normalizeActorNote } from "./normalize.ts";
3
+ import { apiDelete, apiFetch, apiPost, assertOk } from "./fetch.ts";
4
+
5
+ export async function fetchNotes(): Promise<ActorNote[]> {
6
+ const res = await apiFetch("/api/notes");
7
+ await assertOk(res, "Failed to fetch notes");
8
+ const data = (await res.json()) as { notes?: ActorNote[] };
9
+ return (data.notes || []).map(normalizeActorNote);
10
+ }
11
+
12
+ export async function createNote(data: {
13
+ content: string;
14
+ expires_in_hours?: number;
15
+ }): Promise<ActorNote> {
16
+ const res = await apiPost("/api/notes", data);
17
+ await assertOk(res, "Failed to create note");
18
+ const result = (await res.json()) as { note: ActorNote };
19
+ return normalizeActorNote(result.note);
20
+ }
21
+
22
+ export async function deleteMyNote(): Promise<void> {
23
+ const res = await apiDelete("/api/notes/me");
24
+ await assertOk(res, "Failed to delete note");
25
+ }
@@ -10,6 +10,7 @@ export * from "./api/notifications.ts";
10
10
  export * from "./api/search.ts";
11
11
  export * from "./api/media.ts";
12
12
  export * from "./api/stories.ts";
13
+ export * from "./api/notes.ts";
13
14
  export * from "./api/recommendations.ts";
14
15
  export * from "./api/moderation.ts";
15
16
  export * from "./api/normalize.ts";
@@ -183,3 +183,14 @@ export interface ActorStories {
183
183
  stories: Story[];
184
184
  has_unviewed: boolean;
185
185
  }
186
+
187
+ // Short-lived actor status note. This is the Instagram-Notes-style current
188
+ // status surface, not the ActivityPub `Note` object used for normal posts.
189
+ export interface ActorNote {
190
+ actor: PostAuthor;
191
+ content: string;
192
+ created_at: string;
193
+ updated_at: string;
194
+ expires_at: string;
195
+ is_mine: boolean;
196
+ }
@@ -14,6 +14,7 @@ import actorsRoutes from "./routes/actors.ts";
14
14
  import followRoutes from "./routes/follow.ts";
15
15
  import timelineRoutes from "./routes/timeline.ts";
16
16
  import postsRoutes from "./routes/posts.ts";
17
+ import notesRoutes from "./routes/notes.ts";
17
18
  import notificationsRoutes from "./routes/notifications.ts";
18
19
  import storiesRoutes from "./routes/stories.ts";
19
20
  import searchRoutes from "./routes/search.ts";
@@ -218,7 +219,8 @@ function buildSocialServerDiscovery(
218
219
  ...DEFAULT_DISCOVERY_OPTIONS,
219
220
  ...options,
220
221
  clients: options.clients ?? DEFAULT_DISCOVERY_OPTIONS.clients,
221
- capabilities: options.capabilities ?? DEFAULT_DISCOVERY_OPTIONS.capabilities,
222
+ capabilities:
223
+ options.capabilities ?? DEFAULT_DISCOVERY_OPTIONS.capabilities,
222
224
  };
223
225
  return {
224
226
  product: discovery.product,
@@ -599,6 +601,8 @@ function applyGlobalMiddleware(app: YurucommuApp): void {
599
601
  app.use("/media/*", rateLimit(RateLimitConfigs.mediaUpload));
600
602
  app.use("/api/dm/*", rateLimit(RateLimitConfigs.dm));
601
603
  app.post("/api/posts", rateLimit(RateLimitConfigs.postCreate));
604
+ app.post("/api/notes", rateLimit(RateLimitConfigs.postCreate));
605
+ app.delete("/api/notes/me", rateLimit(RateLimitConfigs.postCreate));
602
606
  // Like/repost are federated WRITES (they sign + deliver activities to remote
603
607
  // inboxes), so bound them at the write budget rather than the general read
604
608
  // budget to limit mass-interaction delivery storms.
@@ -676,6 +680,7 @@ function mountCoreRoutes(app: YurucommuApp): void {
676
680
  app.route("/api/follow", followRoutes);
677
681
  app.route("/api/timeline", timelineRoutes);
678
682
  app.route("/api/posts", postsRoutes);
683
+ app.route("/api/notes", notesRoutes);
679
684
 
680
685
  app.get("/api/bookmarks", async (c) => {
681
686
  const url = new URL(c.req.url);
@@ -702,23 +707,35 @@ function mountCoreRoutes(app: YurucommuApp): void {
702
707
 
703
708
  function mountStaticFallback(app: YurucommuApp): void {
704
709
  app.all("*", async (c) => {
710
+ const url = new URL(c.req.url);
705
711
  // A request that reaches the static fallback under a backend route prefix
706
712
  // means no API / AP / media route matched it — return a genuine JSON 404
707
713
  // instead of the SPA HTML shell. Without this, the Cloudflare ASSETS binding
708
714
  // (single-page-application mode) served index.html with a 200 for unmatched
709
715
  // /api/* paths, so an API client (or our own fetch) got HTML 200 instead of
710
716
  // a 404 — the Bun runtime already guarded this; share one source of truth.
711
- if (isBackendPath(new URL(c.req.url).pathname)) {
717
+ if (isBackendPath(url.pathname)) {
712
718
  return c.json({ error: "Not Found", code: "NOT_FOUND" }, 404);
713
719
  }
714
720
 
715
721
  if (c.env.ASSETS) {
716
- return c.env.ASSETS.fetch(c.req.raw);
722
+ const response = await c.env.ASSETS.fetch(c.req.raw);
723
+ const method = c.req.method.toUpperCase();
724
+ if (
725
+ response.status === 404 &&
726
+ (method === "GET" || method === "HEAD") &&
727
+ !url.pathname.includes(".")
728
+ ) {
729
+ const indexUrl = new URL(c.req.url);
730
+ indexUrl.pathname = "/index.html";
731
+ indexUrl.search = "";
732
+ return c.env.ASSETS.fetch(new Request(indexUrl, c.req.raw));
733
+ }
734
+ return response;
717
735
  }
718
736
 
719
737
  const storage = (c.env as { STORAGE?: R2Bucket }).STORAGE;
720
738
  if (storage) {
721
- const url = new URL(c.req.url);
722
739
  let assetPath = url.pathname;
723
740
 
724
741
  if (assetPath === "/" || assetPath === "") {
@@ -319,7 +319,20 @@ async function handleCloudflareCache(
319
319
  const url = new URL(c.req.url);
320
320
  const fullCacheKey = new Request(`${url.origin}/_cache${cacheKey}`);
321
321
 
322
- const cachedResponse = await cache.match(fullCacheKey);
322
+ let cachedResponse: Response | undefined;
323
+ try {
324
+ cachedResponse = await cache.match(fullCacheKey);
325
+ } catch (error) {
326
+ if (isDefaultCacheUnavailable(error)) {
327
+ log.warn("Cloudflare default cache is unavailable; using memory cache", {
328
+ event: "cache.default_unavailable",
329
+ cacheKey,
330
+ error,
331
+ });
332
+ return handleMemoryCache(c, next, cacheKey, config);
333
+ }
334
+ throw error;
335
+ }
323
336
 
324
337
  if (cachedResponse) {
325
338
  const etag = cachedResponse.headers.get("ETag");
@@ -371,6 +384,18 @@ async function handleCloudflareCache(
371
384
  c.res = responseToCache;
372
385
  }
373
386
 
387
+ function isDefaultCacheUnavailable(error: unknown): boolean {
388
+ const message =
389
+ error instanceof Error
390
+ ? error.message
391
+ : typeof error === "string"
392
+ ? error
393
+ : "";
394
+ return /not permitted to access the default cache|default cache.*(?:not|un)available|Cache API.*not available/i.test(
395
+ message,
396
+ );
397
+ }
398
+
374
399
  async function handleMemoryCache(
375
400
  c: HonoContext,
376
401
  next: Next,
@@ -0,0 +1,218 @@
1
+ import { Hono } from "hono";
2
+ import { and, desc, eq, gt, inArray, isNull, or } from "drizzle-orm";
3
+ import type { SQL } from "drizzle-orm";
4
+
5
+ import { actorNotes, actors, follows, type Database } from "../../db/index.ts";
6
+ import type { Env, Variables } from "../types.ts";
7
+ import { formatUsername } from "../federation-helpers.ts";
8
+ import { excludeBlockedMutedAuthors } from "../lib/feed-exclude.ts";
9
+
10
+ const notes = new Hono<{ Bindings: Env; Variables: Variables }>();
11
+
12
+ const MAX_NOTE_CONTENT_LENGTH = 80;
13
+ const DEFAULT_NOTE_TTL_HOURS = 24;
14
+ const MIN_NOTE_TTL_HOURS = 1;
15
+ const MAX_NOTE_TTL_HOURS = 24;
16
+ const MAX_NOTE_FEED_ITEMS = 60;
17
+
18
+ type NoteRow = {
19
+ actorApId: string;
20
+ content: string;
21
+ createdAt: string;
22
+ updatedAt: string;
23
+ expiresAt: string;
24
+ preferredUsername: string;
25
+ name: string | null;
26
+ iconUrl: string | null;
27
+ };
28
+
29
+ type NoteResponse = {
30
+ actor: {
31
+ ap_id: string;
32
+ username: string;
33
+ preferred_username: string;
34
+ name: string | null;
35
+ icon_url: string | null;
36
+ };
37
+ content: string;
38
+ created_at: string;
39
+ updated_at: string;
40
+ expires_at: string;
41
+ is_mine: boolean;
42
+ };
43
+
44
+ function sanitizeContent(input: unknown): string | null {
45
+ if (typeof input !== "string") return null;
46
+ const content = input.trim();
47
+ if (content.length === 0 || content.length > MAX_NOTE_CONTENT_LENGTH) {
48
+ return null;
49
+ }
50
+ return content;
51
+ }
52
+
53
+ function ttlHours(input: unknown): number {
54
+ if (typeof input !== "number" || !Number.isFinite(input)) {
55
+ return DEFAULT_NOTE_TTL_HOURS;
56
+ }
57
+ return Math.min(
58
+ MAX_NOTE_TTL_HOURS,
59
+ Math.max(MIN_NOTE_TTL_HOURS, Math.floor(input)),
60
+ );
61
+ }
62
+
63
+ function formatNote(row: NoteRow, viewerApId: string): NoteResponse {
64
+ return {
65
+ actor: {
66
+ ap_id: row.actorApId,
67
+ username: formatUsername(row.actorApId),
68
+ preferred_username: row.preferredUsername,
69
+ name: row.name,
70
+ icon_url: row.iconUrl,
71
+ },
72
+ content: row.content,
73
+ created_at: row.createdAt,
74
+ updated_at: row.updatedAt,
75
+ expires_at: row.expiresAt,
76
+ is_mine: row.actorApId === viewerApId,
77
+ };
78
+ }
79
+
80
+ async function loadActiveNotes(
81
+ db: Database,
82
+ viewerApId: string,
83
+ ): Promise<NoteRow[]> {
84
+ const now = new Date().toISOString();
85
+ const followingSubquery = db
86
+ .select({ id: follows.followingApId })
87
+ .from(follows)
88
+ .where(
89
+ and(eq(follows.followerApId, viewerApId), eq(follows.status, "accepted")),
90
+ );
91
+
92
+ const filters: SQL[] = [
93
+ isNull(actorNotes.deletedAt),
94
+ isNull(actors.deletedAt),
95
+ gt(actorNotes.expiresAt, now),
96
+ or(
97
+ eq(actorNotes.actorApId, viewerApId),
98
+ inArray(actorNotes.actorApId, followingSubquery),
99
+ )!,
100
+ ];
101
+ const excludeAuthors = excludeBlockedMutedAuthors(
102
+ db,
103
+ viewerApId,
104
+ actorNotes.actorApId,
105
+ );
106
+ if (excludeAuthors) filters.push(excludeAuthors);
107
+
108
+ return await db
109
+ .select({
110
+ actorApId: actorNotes.actorApId,
111
+ content: actorNotes.content,
112
+ createdAt: actorNotes.createdAt,
113
+ updatedAt: actorNotes.updatedAt,
114
+ expiresAt: actorNotes.expiresAt,
115
+ preferredUsername: actors.preferredUsername,
116
+ name: actors.name,
117
+ iconUrl: actors.iconUrl,
118
+ })
119
+ .from(actorNotes)
120
+ .innerJoin(actors, eq(actorNotes.actorApId, actors.apId))
121
+ .where(and(...filters))
122
+ .orderBy(desc(actorNotes.updatedAt))
123
+ .limit(MAX_NOTE_FEED_ITEMS);
124
+ }
125
+
126
+ notes.get("/", async (c) => {
127
+ const actor = c.get("actor");
128
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
129
+
130
+ const rows = await loadActiveNotes(c.get("db"), actor.ap_id);
131
+ return c.json({ notes: rows.map((row) => formatNote(row, actor.ap_id)) });
132
+ });
133
+
134
+ notes.post("/", async (c) => {
135
+ const actor = c.get("actor");
136
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
137
+
138
+ const body = await c.req.json().catch(() => null);
139
+ if (!body || typeof body !== "object") {
140
+ return c.json({ error: "JSON body required" }, 400);
141
+ }
142
+
143
+ const content = sanitizeContent((body as { content?: unknown }).content);
144
+ if (!content) {
145
+ return c.json(
146
+ {
147
+ error: `content must be 1-${MAX_NOTE_CONTENT_LENGTH} characters`,
148
+ },
149
+ 400,
150
+ );
151
+ }
152
+
153
+ const now = new Date().toISOString();
154
+ const expiresAt = new Date(
155
+ Date.now() +
156
+ ttlHours((body as { expires_in_hours?: unknown }).expires_in_hours) *
157
+ 60 *
158
+ 60 *
159
+ 1000,
160
+ ).toISOString();
161
+ const db = c.get("db");
162
+
163
+ await db
164
+ .insert(actorNotes)
165
+ .values({
166
+ actorApId: actor.ap_id,
167
+ content,
168
+ createdAt: now,
169
+ updatedAt: now,
170
+ expiresAt,
171
+ deletedAt: null,
172
+ })
173
+ .onConflictDoUpdate({
174
+ target: actorNotes.actorApId,
175
+ set: {
176
+ content,
177
+ createdAt: now,
178
+ updatedAt: now,
179
+ expiresAt,
180
+ deletedAt: null,
181
+ },
182
+ });
183
+
184
+ return c.json(
185
+ {
186
+ note: formatNote(
187
+ {
188
+ actorApId: actor.ap_id,
189
+ content,
190
+ createdAt: now,
191
+ updatedAt: now,
192
+ expiresAt,
193
+ preferredUsername: actor.preferred_username,
194
+ name: actor.name,
195
+ iconUrl: actor.icon_url,
196
+ },
197
+ actor.ap_id,
198
+ ),
199
+ },
200
+ 201,
201
+ );
202
+ });
203
+
204
+ notes.delete("/me", async (c) => {
205
+ const actor = c.get("actor");
206
+ if (!actor) return c.json({ error: "Unauthorized" }, 401);
207
+
208
+ const now = new Date().toISOString();
209
+ await c
210
+ .get("db")
211
+ .update(actorNotes)
212
+ .set({ deletedAt: now, updatedAt: now, expiresAt: now })
213
+ .where(eq(actorNotes.actorApId, actor.ap_id));
214
+
215
+ return c.json({ success: true });
216
+ });
217
+
218
+ export default notes;
@@ -2,10 +2,12 @@ import { Hono } from "hono";
2
2
  import { sql } from "drizzle-orm";
3
3
  import type { Env, Variables } from "../types.ts";
4
4
  import { formatUsername } from "../federation-helpers.ts";
5
+ import { logger } from "../lib/logger.ts";
5
6
  import { CacheTags, CacheTTL, withCache } from "../middleware/cache.ts";
6
7
  import { batchLoadActorInfo } from "./communities/membership-shared.ts";
7
8
 
8
9
  const recommendations = new Hono<{ Bindings: Env; Variables: Variables }>();
10
+ const log = logger.child({ component: "recommendations" });
9
11
 
10
12
  /**
11
13
  * GET /api/recommendations/users
@@ -28,60 +30,69 @@ recommendations.get(
28
30
  const db = c.get("db");
29
31
  const myApId = actor.ap_id;
30
32
 
31
- const candidates = await db.all<{ ap_id: string; mutual_count: number }>(
32
- sql`
33
- SELECT f2.following_ap_id AS ap_id, COUNT(DISTINCT f2.follower_ap_id) AS mutual_count
34
- FROM follows f1
35
- JOIN follows f2 ON f1.following_ap_id = f2.follower_ap_id AND f2.status = 'accepted'
36
- WHERE f1.follower_ap_id = ${myApId}
37
- AND f1.status = 'accepted'
38
- AND f2.following_ap_id != ${myApId}
39
- AND f2.following_ap_id NOT IN (
40
- SELECT following_ap_id FROM follows
41
- WHERE follower_ap_id = ${myApId} AND status IN ('accepted', 'pending')
42
- )
43
- AND f2.following_ap_id NOT IN (
44
- SELECT blocked_ap_id FROM blocks WHERE blocker_ap_id = ${myApId}
45
- )
46
- AND f2.following_ap_id NOT IN (
47
- SELECT muted_ap_id FROM mutes WHERE muter_ap_id = ${myApId}
48
- )
49
- AND f2.following_ap_id NOT IN (
50
- -- Hide deleted AND private/locked (is_private = 1) accounts. A locked
51
- -- account opted out of discovery (discoverable:false), and every other
52
- -- actor-discovery surface (search.actors, takos-tools searchUsers /
53
- -- getUserProfile) excludes is_private = 1 — this friends-of-friends
54
- -- panel must match or it leaks the locked account's handle/name/icon.
55
- -- is_private lives only on the LOCAL actors table; remote candidates
56
- -- (actorCache) are unaffected, same scope as the other surfaces.
57
- SELECT ap_id FROM actors WHERE deleted_at IS NOT NULL OR is_private = 1
58
- )
59
- GROUP BY f2.following_ap_id
60
- ORDER BY mutual_count DESC
61
- LIMIT 5
62
- `,
63
- );
33
+ try {
34
+ const candidates = await db.all<{ ap_id: string; mutual_count: number }>(
35
+ sql`
36
+ SELECT f2.following_ap_id AS ap_id, COUNT(DISTINCT f2.follower_ap_id) AS mutual_count
37
+ FROM follows f1
38
+ JOIN follows f2 ON f1.following_ap_id = f2.follower_ap_id AND f2.status = 'accepted'
39
+ WHERE f1.follower_ap_id = ${myApId}
40
+ AND f1.status = 'accepted'
41
+ AND f2.following_ap_id != ${myApId}
42
+ AND f2.following_ap_id NOT IN (
43
+ SELECT following_ap_id FROM follows
44
+ WHERE follower_ap_id = ${myApId} AND status IN ('accepted', 'pending')
45
+ )
46
+ AND f2.following_ap_id NOT IN (
47
+ SELECT blocked_ap_id FROM blocks WHERE blocker_ap_id = ${myApId}
48
+ )
49
+ AND f2.following_ap_id NOT IN (
50
+ SELECT muted_ap_id FROM mutes WHERE muter_ap_id = ${myApId}
51
+ )
52
+ AND f2.following_ap_id NOT IN (
53
+ -- Hide deleted AND private/locked (is_private = 1) accounts. A locked
54
+ -- account opted out of discovery (discoverable:false), and every other
55
+ -- actor-discovery surface (search.actors, takos-tools searchUsers /
56
+ -- getUserProfile) excludes is_private = 1 this friends-of-friends
57
+ -- panel must match or it leaks the locked account's handle/name/icon.
58
+ -- is_private lives only on the LOCAL actors table; remote candidates
59
+ -- (actorCache) are unaffected, same scope as the other surfaces.
60
+ SELECT ap_id FROM actors WHERE deleted_at IS NOT NULL OR is_private = 1
61
+ )
62
+ GROUP BY f2.following_ap_id
63
+ ORDER BY mutual_count DESC
64
+ LIMIT 5
65
+ `,
66
+ );
64
67
 
65
- if (candidates.length === 0) return c.json({ users: [] });
68
+ if (candidates.length === 0) return c.json({ users: [] });
66
69
 
67
- const actorMap = await batchLoadActorInfo(
68
- db,
69
- candidates.map((r) => r.ap_id),
70
- );
70
+ const actorMap = await batchLoadActorInfo(
71
+ db,
72
+ candidates.map((r) => r.ap_id),
73
+ );
71
74
 
72
- const users = candidates.map((row) => {
73
- const info = actorMap.get(row.ap_id);
74
- return {
75
- ap_id: row.ap_id,
76
- preferred_username: info?.preferredUsername ?? null,
77
- name: info?.name ?? null,
78
- icon_url: info?.iconUrl ?? null,
79
- username: formatUsername(row.ap_id),
80
- mutual_count: Number(row.mutual_count),
81
- };
82
- });
75
+ const users = candidates.map((row) => {
76
+ const info = actorMap.get(row.ap_id);
77
+ return {
78
+ ap_id: row.ap_id,
79
+ preferred_username: info?.preferredUsername ?? null,
80
+ name: info?.name ?? null,
81
+ icon_url: info?.iconUrl ?? null,
82
+ username: formatUsername(row.ap_id),
83
+ mutual_count: Number(row.mutual_count),
84
+ };
85
+ });
83
86
 
84
- return c.json({ users });
87
+ return c.json({ users });
88
+ } catch (error) {
89
+ log.warn("Recommendations failed; returning empty suggestions", {
90
+ event: "recommendations.failed",
91
+ actorApId: myApId,
92
+ error,
93
+ });
94
+ return c.json({ users: [] });
95
+ }
85
96
  },
86
97
  );
87
98
 
@@ -12,6 +12,7 @@ export * from "./social.ts";
12
12
  export * from "./reports.ts";
13
13
  export * from "./communities.ts";
14
14
  export * from "./stories.ts";
15
+ export * from "./notes.ts";
15
16
  export * from "./messaging.ts";
16
17
  export * from "./mobile.ts";
17
18
  export * from "./relations.ts";
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Short-lived actor status notes.
3
+ */
4
+
5
+ import { index, sqliteTable, text } from "drizzle-orm/sqlite-core";
6
+ import { nowIso } from "./date-utils.ts";
7
+
8
+ export const actorNotes = sqliteTable(
9
+ "actor_notes",
10
+ {
11
+ actorApId: text("actor_ap_id").primaryKey(),
12
+ content: text("content").notNull(),
13
+ createdAt: text("created_at").notNull().$defaultFn(nowIso),
14
+ updatedAt: text("updated_at")
15
+ .notNull()
16
+ .$defaultFn(nowIso)
17
+ .$onUpdateFn(nowIso),
18
+ expiresAt: text("expires_at").notNull(),
19
+ deletedAt: text("deleted_at"),
20
+ },
21
+ (t) => [
22
+ index("actor_notes_expires_idx").on(t.expiresAt),
23
+ index("actor_notes_updated_idx").on(t.updatedAt),
24
+ index("actor_notes_deleted_idx").on(t.deletedAt),
25
+ ],
26
+ );
@@ -1,67 +0,0 @@
1
- import { expect, test } from "bun:test";
2
- import { clearYurucommuApiTransport } from "../transport.ts";
3
- import type { DMMessage } from "../../types/index.ts";
4
- import { fetchUserDMMessages } from "./dm.ts";
5
-
6
- function makeMessage(id: string): DMMessage {
7
- return {
8
- id,
9
- sender: {
10
- ap_id: "https://example.com/ap/users/alice",
11
- username: "alice@example.com",
12
- preferred_username: "alice",
13
- name: "Alice",
14
- icon_url: null,
15
- },
16
- content: "hi",
17
- attachments: [],
18
- created_at: "2026-01-01T00:00:00.000Z",
19
- } as unknown as DMMessage;
20
- }
21
-
22
- async function withMockFetch<T>(
23
- responseBody: unknown,
24
- fn: () => Promise<T>,
25
- ): Promise<T> {
26
- const originalFetch = globalThis.fetch;
27
- clearYurucommuApiTransport();
28
- globalThis.fetch = ((_input: RequestInfo | URL) =>
29
- Promise.resolve(
30
- new Response(JSON.stringify(responseBody), {
31
- status: 200,
32
- headers: { "Content-Type": "application/json" },
33
- }),
34
- )) as typeof fetch;
35
- try {
36
- return await fn();
37
- } finally {
38
- globalThis.fetch = originalFetch;
39
- clearYurucommuApiTransport();
40
- }
41
- }
42
-
43
- // Regression: the DM thread's "load older" affordance depends on the client
44
- // surfacing the server's `has_more` flag (it was previously discarded).
45
- test("fetchUserDMMessages surfaces has_more as hasMore and maps messages", async () => {
46
- const result = await withMockFetch(
47
- {
48
- messages: [makeMessage("m1")],
49
- conversation_id: "conv-1",
50
- has_more: true,
51
- },
52
- () => fetchUserDMMessages("https://example.com/ap/users/alice"),
53
- );
54
-
55
- expect(result.messages.map((m) => m.id)).toEqual(["m1"]);
56
- expect(result.conversation_id).toBe("conv-1");
57
- expect(result.hasMore).toBe(true);
58
- });
59
-
60
- test("fetchUserDMMessages defaults hasMore to false when the server omits it", async () => {
61
- const result = await withMockFetch({ messages: [] }, () =>
62
- fetchUserDMMessages("https://example.com/ap/users/alice"),
63
- );
64
-
65
- expect(result.messages).toEqual([]);
66
- expect(result.hasMore).toBe(false);
67
- });
@@ -1,63 +0,0 @@
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
- });
@@ -1,110 +0,0 @@
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
- });