@takosjp/yurucommu-core 3.4.3 → 3.4.4

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 (112) hide show
  1. package/migrations/0023_delivery_resolution_outbox.sql +33 -0
  2. package/migrations/0024_delivery_fanout_outbox.sql +35 -0
  3. package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
  4. package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
  5. package/migrations/0027_remote_actor_tombstones.sql +12 -0
  6. package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
  7. package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
  8. package/package.json +2 -1
  9. package/packages/api/package.json +1 -1
  10. package/packages/api/src/lib/api/communities.ts +2 -0
  11. package/packages/api/src/lib/api/fetch.ts +69 -13
  12. package/packages/api/src/lib/api/normalize.ts +32 -10
  13. package/packages/api/src/lib/api/notifications.ts +4 -1
  14. package/packages/api/src/lib/rtc-client.ts +127 -29
  15. package/src/backend/federation-helpers.ts +1 -0
  16. package/src/backend/index.ts +16 -3
  17. package/src/backend/lib/account-migration.ts +340 -0
  18. package/src/backend/lib/activity-delete-cascade.ts +193 -0
  19. package/src/backend/lib/activitypub-actor-cache.ts +615 -48
  20. package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
  21. package/src/backend/lib/activitypub-actor-identity.ts +39 -0
  22. package/src/backend/lib/activitypub-validators.ts +62 -4
  23. package/src/backend/lib/ap-ids.ts +36 -6
  24. package/src/backend/lib/ap-verify.ts +7 -17
  25. package/src/backend/lib/blocklist-purge.ts +676 -42
  26. package/src/backend/lib/blocklist.ts +278 -39
  27. package/src/backend/lib/community-visibility.ts +47 -33
  28. package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
  29. package/src/backend/lib/delivery/planner.ts +16 -12
  30. package/src/backend/lib/delivery/queue-batching.ts +389 -145
  31. package/src/backend/lib/delivery/queue-delivery.ts +47 -25
  32. package/src/backend/lib/delivery/queue.ts +479 -73
  33. package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
  34. package/src/backend/lib/delivery/types.ts +31 -7
  35. package/src/backend/lib/feed-exclude.ts +40 -28
  36. package/src/backend/lib/follow-edge-mutations.ts +217 -0
  37. package/src/backend/lib/notification-eligibility.ts +15 -9
  38. package/src/backend/lib/notification-push.ts +2 -2
  39. package/src/backend/lib/oidc-id-token.ts +66 -0
  40. package/src/backend/lib/personal-actor-moderation.ts +239 -0
  41. package/src/backend/lib/post-visibility.ts +79 -16
  42. package/src/backend/lib/remote-activity-id.ts +61 -0
  43. package/src/backend/lib/unread-counts.ts +3 -0
  44. package/src/backend/retention.ts +38 -1
  45. package/src/backend/routes/account-teardown.ts +422 -156
  46. package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
  47. package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
  48. package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
  49. package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
  50. package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
  51. package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
  52. package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
  53. package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
  54. package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
  55. package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
  56. package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
  57. package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
  58. package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
  59. package/src/backend/routes/activitypub/inbox-types.ts +14 -2
  60. package/src/backend/routes/activitypub/inbox.ts +81 -105
  61. package/src/backend/routes/activitypub/outbox.ts +0 -0
  62. package/src/backend/routes/activitypub.ts +6 -5
  63. package/src/backend/routes/actors-helpers.ts +34 -8
  64. package/src/backend/routes/actors.ts +328 -164
  65. package/src/backend/routes/auth-helpers.ts +57 -9
  66. package/src/backend/routes/auth.ts +10 -2
  67. package/src/backend/routes/communities/membership-invites.ts +4 -1
  68. package/src/backend/routes/communities/membership-members.ts +201 -73
  69. package/src/backend/routes/communities/membership-requests.ts +141 -76
  70. package/src/backend/routes/communities/membership-shared.ts +228 -21
  71. package/src/backend/routes/communities/messages.ts +9 -2
  72. package/src/backend/routes/communities/routes.ts +48 -13
  73. package/src/backend/routes/dm/contacts.ts +29 -41
  74. package/src/backend/routes/dm/conversations-helpers.ts +9 -1
  75. package/src/backend/routes/dm/messages.ts +36 -42
  76. package/src/backend/routes/dm/read-archive.ts +4 -2
  77. package/src/backend/routes/dm/requests.ts +62 -54
  78. package/src/backend/routes/follow-helpers.ts +200 -60
  79. package/src/backend/routes/follow.ts +146 -140
  80. package/src/backend/routes/media.ts +20 -94
  81. package/src/backend/routes/moderation.ts +72 -4
  82. package/src/backend/routes/notes.ts +3 -4
  83. package/src/backend/routes/notifications.ts +209 -108
  84. package/src/backend/routes/posts/delete-cascade.ts +253 -89
  85. package/src/backend/routes/posts/federation.ts +373 -0
  86. package/src/backend/routes/posts/interactions.ts +160 -184
  87. package/src/backend/routes/posts/like-mutation.ts +240 -0
  88. package/src/backend/routes/posts/post-helpers.ts +112 -162
  89. package/src/backend/routes/posts/queries.ts +150 -54
  90. package/src/backend/routes/posts/routes.ts +169 -329
  91. package/src/backend/routes/posts/transformers.ts +61 -6
  92. package/src/backend/routes/recommendations.ts +37 -44
  93. package/src/backend/routes/rtc/index.ts +26 -6
  94. package/src/backend/routes/search.ts +39 -55
  95. package/src/backend/routes/stories/interactions.ts +22 -6
  96. package/src/backend/routes/stories/query-helpers.ts +28 -41
  97. package/src/backend/routes/stories/routes.ts +125 -108
  98. package/src/backend/routes/takos-tools/dm.ts +17 -17
  99. package/src/backend/routes/takos-tools/posts.ts +189 -106
  100. package/src/backend/routes/takos-tools/search.ts +13 -4
  101. package/src/backend/routes/takos-tools/timeline.ts +35 -27
  102. package/src/backend/routes/takos-tools-response.ts +6 -2
  103. package/src/backend/routes/timeline.ts +5 -5
  104. package/src/backend/runtime/call-signaling-do.ts +35 -4
  105. package/src/backend/runtime/one-time-ticket.ts +116 -0
  106. package/src/backend/runtime/realtime-stream-do.ts +5 -61
  107. package/src/backend/runtime/signaling-hub.ts +36 -3
  108. package/src/backend/server.ts +95 -80
  109. package/src/db/d1-write.ts +67 -43
  110. package/src/db/schema/federation.ts +42 -1
  111. package/src/db/schema/messaging.ts +110 -2
  112. package/src/db/schema.ts +1 -1
@@ -0,0 +1,33 @@
1
+ -- Durable first-hop outbox for outbound recipients whose inbox endpoint is not
2
+ -- yet known. Queue publication is not durable evidence: the producer RPC can
3
+ -- fail after the Activity has committed and before delivery_queue has an
4
+ -- endpoint job. Retain the (activity, recipient) intent until resolution has
5
+ -- either produced that endpoint job or reached a bounded terminal state.
6
+ --
7
+ -- No foreign keys: Activity deletion owns explicit projection cleanup, matching
8
+ -- delivery_queue and the rest of the production D1-safe federation ledger.
9
+ CREATE TABLE IF NOT EXISTS delivery_resolutions (
10
+ id TEXT PRIMARY KEY,
11
+ activity_ap_id TEXT NOT NULL,
12
+ recipient_actor_ap_id TEXT NOT NULL,
13
+ status TEXT NOT NULL DEFAULT 'pending',
14
+ processing_token TEXT,
15
+ attempts INTEGER NOT NULL DEFAULT 0,
16
+ next_attempt_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
17
+ last_error TEXT,
18
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
19
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
20
+ resolved_at TEXT
21
+ );
22
+
23
+ CREATE UNIQUE INDEX IF NOT EXISTS delivery_resolutions_activity_actor_idx
24
+ ON delivery_resolutions(activity_ap_id, recipient_actor_ap_id);
25
+
26
+ CREATE INDEX IF NOT EXISTS delivery_resolutions_status_next_idx
27
+ ON delivery_resolutions(status, next_attempt_at);
28
+
29
+ CREATE INDEX IF NOT EXISTS delivery_resolutions_terminal_retention_idx
30
+ ON delivery_resolutions(status, updated_at);
31
+
32
+ CREATE INDEX IF NOT EXISTS delivery_resolutions_activity_idx
33
+ ON delivery_resolutions(activity_ap_id);
@@ -0,0 +1,35 @@
1
+ -- Durable first-hop outbox for follower/community fanout. The Activity is
2
+ -- committed before the initial Queue RPC, so a producer outage must leave a
3
+ -- recoverable intent instead of silently losing every recipient plan.
4
+ --
5
+ -- Queue messages are wakeups. Retain the intent through publication until the
6
+ -- final fanout page completes; this also lets the Bun self-host rebuild its
7
+ -- in-memory queue after a process restart.
8
+ --
9
+ -- No foreign keys: Activity deletion owns explicit projection cleanup, matching
10
+ -- delivery_queue and delivery_resolutions on production D1.
11
+ CREATE TABLE IF NOT EXISTS delivery_fanouts (
12
+ id TEXT PRIMARY KEY,
13
+ activity_ap_id TEXT NOT NULL,
14
+ kind TEXT NOT NULL,
15
+ target_ap_id TEXT NOT NULL,
16
+ announce_activity_ap_id TEXT,
17
+ status TEXT NOT NULL DEFAULT 'pending',
18
+ publications INTEGER NOT NULL DEFAULT 0,
19
+ last_error TEXT,
20
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
21
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
22
+ completed_at TEXT
23
+ );
24
+
25
+ CREATE UNIQUE INDEX IF NOT EXISTS delivery_fanouts_intent_idx
26
+ ON delivery_fanouts(activity_ap_id, kind, target_ap_id, announce_activity_ap_id);
27
+
28
+ CREATE INDEX IF NOT EXISTS delivery_fanouts_status_created_idx
29
+ ON delivery_fanouts(status, created_at);
30
+
31
+ CREATE INDEX IF NOT EXISTS delivery_fanouts_terminal_retention_idx
32
+ ON delivery_fanouts(status, updated_at);
33
+
34
+ CREATE INDEX IF NOT EXISTS delivery_fanouts_activity_idx
35
+ ON delivery_fanouts(activity_ap_id);
@@ -0,0 +1,6 @@
1
+ -- Endpoint delivery jobs are a durable retry/idempotency ledger, but terminal
2
+ -- rows for Activities that remain in history otherwise grow without bound.
3
+ -- Retention keeps 30 days and deletes only bounded batches; index that scan
4
+ -- without rewriting or resetting protected production data.
5
+ CREATE INDEX IF NOT EXISTS delivery_queue_terminal_retention_idx
6
+ ON delivery_queue(status, created_at);
@@ -0,0 +1,29 @@
1
+ -- A missing actor_cache row can still be referenced by a durable remote
2
+ -- Follow edge. Profile recovery must distinguish terminal Gone from transient
3
+ -- network/remote failures without re-fetching the same hostile or unavailable
4
+ -- actor on every local page view. Keep that negative-cache/backoff authority
5
+ -- separate from actor_cache so a failure can never masquerade as an actor.
6
+ --
7
+ -- Additive only: protected existing data and prior migration history remain
8
+ -- untouched. Remote actor IDs intentionally have no actors/actor_cache FK.
9
+ CREATE TABLE IF NOT EXISTS remote_actor_fetch_failures (
10
+ actor_ap_id TEXT PRIMARY KEY,
11
+ kind TEXT NOT NULL,
12
+ reason TEXT NOT NULL,
13
+ http_status INTEGER,
14
+ failure_count INTEGER NOT NULL DEFAULT 1,
15
+ retry_at TEXT,
16
+ processing_token TEXT,
17
+ lease_expires_at TEXT,
18
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
19
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
20
+ );
21
+
22
+ CREATE INDEX IF NOT EXISTS remote_actor_fetch_failures_retry_idx
23
+ ON remote_actor_fetch_failures(retry_at);
24
+
25
+ CREATE INDEX IF NOT EXISTS remote_actor_fetch_failures_lease_idx
26
+ ON remote_actor_fetch_failures(lease_expires_at);
27
+
28
+ CREATE INDEX IF NOT EXISTS remote_actor_fetch_failures_updated_idx
29
+ ON remote_actor_fetch_failures(updated_at);
@@ -0,0 +1,12 @@
1
+ -- A verified inbound Delete(Actor) is durable lifecycle authority, not a
2
+ -- transient actor-document fetch failure. Keep it separate from both
3
+ -- actor_cache and remote_actor_fetch_failures so a late successful fetch
4
+ -- cannot recreate an identity after its teardown committed.
5
+ --
6
+ -- Additive only: protected existing data and prior migration history remain
7
+ -- untouched. Remote actor IDs intentionally have no actors/actor_cache FK.
8
+ CREATE TABLE IF NOT EXISTS remote_actor_tombstones (
9
+ actor_ap_id TEXT PRIMARY KEY,
10
+ delete_activity_ap_id TEXT NOT NULL,
11
+ deleted_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
12
+ );
@@ -0,0 +1,30 @@
1
+ -- A verified remote Actor Delete is durable recipient authority. Keep the
2
+ -- fence at the delivery_resolutions write boundary so every caller, including
3
+ -- a stale fanout page or endpoint-invalidation replay, converges under either
4
+ -- serialization order:
5
+ -- 1. resolution INSERT first -> the Actor Delete batch removes it;
6
+ -- 2. tombstone first -> this trigger removes the inserted row atomically.
7
+ --
8
+ -- AFTER INSERT + targeted DELETE is deliberate. It applies independently to
9
+ -- every row in a multi-row INSERT without aborting unrelated recipients that
10
+ -- may share the same fanout page.
11
+ CREATE TRIGGER IF NOT EXISTS delivery_resolutions_remote_tombstone_fence
12
+ AFTER INSERT ON delivery_resolutions
13
+ WHEN EXISTS (
14
+ SELECT 1
15
+ FROM remote_actor_tombstones
16
+ WHERE actor_ap_id = NEW.recipient_actor_ap_id
17
+ )
18
+ BEGIN
19
+ DELETE FROM delivery_resolutions WHERE id = NEW.id;
20
+ END;
21
+
22
+ -- Converge any row that was written after 0027 established a tombstone but
23
+ -- before this fence was installed. These jobs must not contact a self-deleted
24
+ -- identity; processResolveActor already treats that state as terminal.
25
+ DELETE FROM delivery_resolutions
26
+ WHERE EXISTS (
27
+ SELECT 1
28
+ FROM remote_actor_tombstones
29
+ WHERE actor_ap_id = delivery_resolutions.recipient_actor_ap_id
30
+ );
@@ -0,0 +1,47 @@
1
+ -- Endpoint jobs aggregate by (Activity, inbox endpoint), especially when
2
+ -- several remote actors share one inbox. Retain the recipient identities that
3
+ -- justified each NEW job so a later verified Actor Delete can remove only that
4
+ -- actor and cancel the endpoint job only when no live recipient remains.
5
+ --
6
+ -- Existing jobs predate this authority and cannot be reconstructed safely.
7
+ -- Keep them explicitly unattributed (0); new materialization sets the flag to
8
+ -- 1 only when it creates the job under the recipient-aware implementation.
9
+ ALTER TABLE delivery_queue
10
+ ADD COLUMN recipient_attribution_complete INTEGER NOT NULL DEFAULT 0;
11
+
12
+ CREATE TABLE IF NOT EXISTS delivery_endpoint_recipients (
13
+ delivery_job_id TEXT NOT NULL,
14
+ recipient_actor_ap_id TEXT NOT NULL,
15
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
16
+ PRIMARY KEY (delivery_job_id, recipient_actor_ap_id)
17
+ );
18
+
19
+ CREATE INDEX IF NOT EXISTS delivery_endpoint_recipients_actor_idx
20
+ ON delivery_endpoint_recipients(recipient_actor_ap_id, delivery_job_id);
21
+
22
+ -- Projection cleanup is owned by delivery_queue deletion. Avoid a foreign key:
23
+ -- the engine's production D1 schema uses explicit projection cleanup so remote
24
+ -- identities and migration rebuilds do not diverge between runtimes.
25
+ CREATE TRIGGER IF NOT EXISTS delivery_endpoint_recipients_after_job_delete
26
+ AFTER DELETE ON delivery_queue
27
+ BEGIN
28
+ DELETE FROM delivery_endpoint_recipients
29
+ WHERE delivery_job_id = OLD.id;
30
+ END;
31
+
32
+ -- The attribution write is itself race-fenced. If Delete won before a stale
33
+ -- planner/materializer batch, strip only the deleted actor's mapping; the
34
+ -- materializer's final empty-job cleanup then decides whether the endpoint job
35
+ -- still represents another recipient.
36
+ CREATE TRIGGER IF NOT EXISTS delivery_endpoint_recipients_remote_tombstone_fence
37
+ AFTER INSERT ON delivery_endpoint_recipients
38
+ WHEN EXISTS (
39
+ SELECT 1
40
+ FROM remote_actor_tombstones
41
+ WHERE actor_ap_id = NEW.recipient_actor_ap_id
42
+ )
43
+ BEGIN
44
+ DELETE FROM delivery_endpoint_recipients
45
+ WHERE delivery_job_id = NEW.delivery_job_id
46
+ AND recipient_actor_ap_id = NEW.recipient_actor_ap_id;
47
+ END;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-core",
3
- "version": "3.4.3",
3
+ "version": "3.4.4",
4
4
  "license": "AGPL-3.0-only",
5
5
  "type": "module",
6
6
  "workspaces": [
@@ -67,6 +67,7 @@
67
67
  "build:api": "cd packages/api && bun run build",
68
68
  "check:release-contents": "bun scripts/check-release-contents.mjs",
69
69
  "check:packed-consumer": "bun scripts/check-packed-consumer.mjs",
70
+ "deploy": "bun scripts/deploy.mjs",
70
71
  "prepublishOnly": "bun scripts/check-publish-version-discipline.mjs .",
71
72
  "pack:core": "npm pack --dry-run",
72
73
  "pack:api": "cd packages/api && bun run pack:dry",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@takosjp/yurucommu-api",
3
- "version": "3.4.3",
3
+ "version": "3.4.4",
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",
@@ -36,6 +36,8 @@ export interface CommunityMember {
36
36
  icon_url: string | null;
37
37
  role: "owner" | "moderator" | "member";
38
38
  joined_at: string;
39
+ /** False for Follow-backed federated membership, which has no local role row. */
40
+ can_change_role?: boolean;
39
41
  }
40
42
 
41
43
  export interface CommunityMessage {
@@ -14,9 +14,16 @@ import { getYurucommuApiTransport } from "../transport.ts";
14
14
  * Custom error class for API responses that includes the HTTP status code.
15
15
  */
16
16
  export class ApiError extends Error {
17
+ public readonly code: string | null;
18
+ public readonly retryAfterSeconds: number | null;
19
+
17
20
  constructor(
18
21
  public readonly status: number,
19
22
  message: string,
23
+ details: {
24
+ readonly code?: string | null;
25
+ readonly retryAfterSeconds?: number | null;
26
+ } = {},
20
27
  ) {
21
28
  // `message` is the human-facing server error (or a caller fallback); the
22
29
  // HTTP status lives on `.status`. Keep `.message` clean — many UI surfaces
@@ -24,40 +31,89 @@ export class ApiError extends Error {
24
31
  // reads as technical noise (e.g. "422: Add this account as an alias…").
25
32
  super(message);
26
33
  this.name = "ApiError";
34
+ this.code = details.code ?? null;
35
+ this.retryAfterSeconds = details.retryAfterSeconds ?? null;
27
36
  }
28
37
  }
29
38
 
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(
39
+ interface ApiErrorDetails {
40
+ readonly message: string;
41
+ readonly code: string | null;
42
+ readonly retryAfterSeconds: number | null;
43
+ }
44
+
45
+ function parseRetryAfterSeconds(
46
+ bodyValue: unknown,
47
+ headerValue: string | null,
48
+ ): number | null {
49
+ const bodySeconds =
50
+ typeof bodyValue === "number" && Number.isFinite(bodyValue) && bodyValue > 0
51
+ ? Math.ceil(bodyValue)
52
+ : null;
53
+ if (bodySeconds !== null) return bodySeconds;
54
+ if (!headerValue) return null;
55
+ const numeric = Number(headerValue);
56
+ if (Number.isFinite(numeric) && numeric > 0) return Math.ceil(numeric);
57
+ const dateMs = Date.parse(headerValue);
58
+ if (!Number.isFinite(dateMs) || dateMs <= Date.now()) return null;
59
+ return Math.ceil((dateMs - Date.now()) / 1000);
60
+ }
61
+
62
+ async function extractApiErrorDetails(
35
63
  res: Response,
36
64
  fallback: string,
37
- ): Promise<string> {
65
+ ): Promise<ApiErrorDetails> {
38
66
  try {
39
67
  const data = (await res.json()) as {
40
68
  error?: string | { message?: string };
69
+ code?: unknown;
70
+ retry_after?: unknown;
41
71
  };
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
72
  const err = data.error;
46
73
  const message = typeof err === "string" ? err : err?.message;
47
- return message || fallback;
74
+ const code =
75
+ typeof data.code === "string" && data.code.trim().length > 0
76
+ ? data.code.trim().slice(0, 100)
77
+ : null;
78
+ return {
79
+ message: message || fallback,
80
+ code,
81
+ retryAfterSeconds: parseRetryAfterSeconds(
82
+ data.retry_after,
83
+ res.headers.get("retry-after"),
84
+ ),
85
+ };
48
86
  } catch {
49
- return res.statusText || fallback;
87
+ return {
88
+ message: res.statusText || fallback,
89
+ code: null,
90
+ retryAfterSeconds: parseRetryAfterSeconds(
91
+ null,
92
+ res.headers.get("retry-after"),
93
+ ),
94
+ };
50
95
  }
51
96
  }
52
97
 
98
+ /**
99
+ * Read an error message from a failed API response. Attempts to parse JSON
100
+ * with an `error` field; falls back to `statusText`.
101
+ */
102
+ export async function extractErrorMessage(
103
+ res: Response,
104
+ fallback: string,
105
+ ): Promise<string> {
106
+ return (await extractApiErrorDetails(res, fallback)).message;
107
+ }
108
+
53
109
  /**
54
110
  * Assert that a response is OK. Throws an `ApiError` with the status code
55
111
  * and a message extracted from the response body when it is not.
56
112
  */
57
113
  export async function assertOk(res: Response, fallback: string): Promise<void> {
58
114
  if (!res.ok) {
59
- const message = await extractErrorMessage(res, fallback);
60
- throw new ApiError(res.status, message);
115
+ const details = await extractApiErrorDetails(res, fallback);
116
+ throw new ApiError(res.status, details.message, details);
61
117
  }
62
118
  }
63
119
 
@@ -11,35 +11,57 @@ import { resolveNotificationTarget } from "./notification-target.ts";
11
11
  type ActorLike = {
12
12
  ap_id: string;
13
13
  username?: string;
14
- preferred_username?: string;
14
+ preferred_username?: string | null;
15
+ };
16
+
17
+ type NormalizedActor<T extends ActorLike> = Omit<
18
+ T,
19
+ "username" | "preferred_username"
20
+ > & {
21
+ username: string;
22
+ preferred_username: string;
15
23
  };
16
24
 
17
25
  function formatUsernameFromApId(
18
26
  apId: string,
19
- preferred?: string,
27
+ preferred?: string | null,
20
28
  ): string | null {
21
29
  try {
22
30
  const url = new URL(apId);
23
- const match = apId.match(/\/(users|groups)\/([^/]+)$/);
24
- if (match) return `${match[2]}@${url.host}`;
25
31
  if (preferred) return `${preferred}@${url.host}`;
32
+ const match = url.pathname.match(/\/(users|groups)\/([^/]+)\/?$/u);
33
+ if (match) return `${match[2]}@${url.host}`;
26
34
  } catch {
27
35
  // Ignore malformed URLs and fallback to existing fields.
28
36
  }
29
37
  return null;
30
38
  }
31
39
 
32
- export function normalizeActor<T extends ActorLike>(actor: T): T {
33
- if (!actor || !actor.ap_id) return actor;
40
+ export function normalizeActor<T extends ActorLike>(
41
+ actor: T,
42
+ ): NormalizedActor<T> {
34
43
  const rawUsername = actor.username?.trim();
44
+ const declaredPreferred = actor.preferred_username?.trim() || null;
45
+ const rawSeparator = rawUsername?.lastIndexOf("@") ?? -1;
46
+ const rebasedRawUsername =
47
+ declaredPreferred &&
48
+ rawUsername &&
49
+ !rawUsername.includes("://") &&
50
+ rawSeparator > 0 &&
51
+ rawSeparator < rawUsername.length - 1
52
+ ? `${declaredPreferred}@${rawUsername.slice(rawSeparator + 1)}`
53
+ : null;
35
54
  const formatted =
36
- rawUsername ||
37
- formatUsernameFromApId(actor.ap_id, actor.preferred_username) ||
38
- actor.preferred_username ||
55
+ rebasedRawUsername ||
56
+ (declaredPreferred
57
+ ? formatUsernameFromApId(actor.ap_id, declaredPreferred)
58
+ : rawUsername) ||
59
+ formatUsernameFromApId(actor.ap_id) ||
60
+ declaredPreferred ||
39
61
  actor.username ||
40
62
  actor.ap_id;
41
63
  const preferred =
42
- actor.preferred_username?.trim() ||
64
+ declaredPreferred ||
43
65
  (formatted.includes("@") ? formatted.split("@")[0] : formatted);
44
66
 
45
67
  return {
@@ -45,7 +45,10 @@ export async function fetchUnreadCount(): Promise<number> {
45
45
  }
46
46
 
47
47
  export async function markNotificationsRead(ids?: string[]): Promise<void> {
48
- const res = await apiPost("/api/notifications/read", { ids });
48
+ const res = await apiPost(
49
+ "/api/notifications/read",
50
+ ids === undefined ? { read_all: true } : { ids },
51
+ );
49
52
  await assertOk(res, "Failed to mark as read");
50
53
  }
51
54