@takosjp/yurucommu-core 3.4.3 → 3.4.5
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.
- package/migrations/0023_delivery_resolution_outbox.sql +33 -0
- package/migrations/0024_delivery_fanout_outbox.sql +35 -0
- package/migrations/0025_delivery_endpoint_terminal_retention.sql +6 -0
- package/migrations/0026_remote_actor_fetch_failures.sql +29 -0
- package/migrations/0027_remote_actor_tombstones.sql +12 -0
- package/migrations/0028_remote_actor_delivery_fence.sql +30 -0
- package/migrations/0029_delivery_endpoint_recipients.sql +47 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/lib/api/communities.ts +2 -0
- package/packages/api/src/lib/api/fetch.ts +69 -13
- package/packages/api/src/lib/api/normalize.ts +32 -10
- package/packages/api/src/lib/api/notifications.ts +4 -1
- package/packages/api/src/lib/rtc-client.ts +127 -29
- package/src/backend/federation-helpers.ts +1 -0
- package/src/backend/index.ts +16 -3
- package/src/backend/lib/account-migration.ts +340 -0
- package/src/backend/lib/activity-delete-cascade.ts +193 -0
- package/src/backend/lib/activitypub-actor-cache.ts +615 -48
- package/src/backend/lib/activitypub-actor-identity-sql.ts +179 -0
- package/src/backend/lib/activitypub-actor-identity.ts +39 -0
- package/src/backend/lib/activitypub-validators.ts +62 -4
- package/src/backend/lib/ap-ids.ts +36 -6
- package/src/backend/lib/ap-verify.ts +7 -17
- package/src/backend/lib/blocklist-purge.ts +676 -42
- package/src/backend/lib/blocklist.ts +278 -39
- package/src/backend/lib/community-visibility.ts +47 -33
- package/src/backend/lib/delivery/fanout-outbox.ts +336 -0
- package/src/backend/lib/delivery/planner.ts +16 -12
- package/src/backend/lib/delivery/queue-batching.ts +389 -145
- package/src/backend/lib/delivery/queue-delivery.ts +47 -25
- package/src/backend/lib/delivery/queue.ts +479 -73
- package/src/backend/lib/delivery/resolution-outbox.ts +456 -0
- package/src/backend/lib/delivery/types.ts +31 -7
- package/src/backend/lib/feed-exclude.ts +40 -28
- package/src/backend/lib/follow-edge-mutations.ts +217 -0
- package/src/backend/lib/notification-eligibility.ts +15 -9
- package/src/backend/lib/notification-push.ts +2 -2
- package/src/backend/lib/oidc-id-token.ts +66 -0
- package/src/backend/lib/personal-actor-moderation.ts +239 -0
- package/src/backend/lib/post-visibility.ts +79 -16
- package/src/backend/lib/remote-activity-id.ts +61 -0
- package/src/backend/lib/unread-counts.ts +3 -0
- package/src/backend/retention.ts +38 -1
- package/src/backend/routes/account-teardown.ts +422 -156
- package/src/backend/routes/activitypub/handlers/actor-inbox-handlers.ts +104 -105
- package/src/backend/routes/activitypub/handlers/inbound-community-scope.ts +121 -0
- package/src/backend/routes/activitypub/handlers/inbound-object-identity.ts +54 -0
- package/src/backend/routes/activitypub/handlers/inbound-reply-target.ts +34 -0
- package/src/backend/routes/activitypub/handlers/inbound-story-projection.ts +438 -0
- package/src/backend/routes/activitypub/handlers/inbox-content-handlers.ts +1121 -806
- package/src/backend/routes/activitypub/handlers/inbox-follow-handlers.ts +118 -153
- package/src/backend/routes/activitypub/handlers/inbox-interaction-handlers.ts +185 -168
- package/src/backend/routes/activitypub/handlers/inbox-shared-helpers.ts +220 -32
- package/src/backend/routes/activitypub/inbound-activity-identity.ts +16 -0
- package/src/backend/routes/activitypub/inbound-activity-reference.ts +116 -0
- package/src/backend/routes/activitypub/inbound-addressing.ts +87 -0
- package/src/backend/routes/activitypub/inbox-addressing.ts +22 -19
- package/src/backend/routes/activitypub/inbox-types.ts +14 -2
- package/src/backend/routes/activitypub/inbox.ts +81 -105
- package/src/backend/routes/activitypub/outbox.ts +0 -0
- package/src/backend/routes/activitypub.ts +6 -5
- package/src/backend/routes/actors-helpers.ts +34 -8
- package/src/backend/routes/actors.ts +328 -164
- package/src/backend/routes/auth-helpers.ts +57 -9
- package/src/backend/routes/auth.ts +10 -2
- package/src/backend/routes/communities/membership-invites.ts +4 -1
- package/src/backend/routes/communities/membership-members.ts +201 -73
- package/src/backend/routes/communities/membership-requests.ts +141 -76
- package/src/backend/routes/communities/membership-shared.ts +228 -21
- package/src/backend/routes/communities/messages.ts +9 -2
- package/src/backend/routes/communities/routes.ts +48 -13
- package/src/backend/routes/dm/contacts.ts +29 -41
- package/src/backend/routes/dm/conversations-helpers.ts +9 -1
- package/src/backend/routes/dm/messages.ts +36 -42
- package/src/backend/routes/dm/read-archive.ts +4 -2
- package/src/backend/routes/dm/requests.ts +62 -54
- package/src/backend/routes/follow-helpers.ts +200 -60
- package/src/backend/routes/follow.ts +146 -140
- package/src/backend/routes/media.ts +21 -95
- package/src/backend/routes/moderation.ts +72 -4
- package/src/backend/routes/notes.ts +3 -4
- package/src/backend/routes/notifications.ts +209 -108
- package/src/backend/routes/posts/delete-cascade.ts +253 -89
- package/src/backend/routes/posts/federation.ts +373 -0
- package/src/backend/routes/posts/interactions.ts +160 -184
- package/src/backend/routes/posts/like-mutation.ts +240 -0
- package/src/backend/routes/posts/post-helpers.ts +112 -162
- package/src/backend/routes/posts/queries.ts +150 -54
- package/src/backend/routes/posts/routes.ts +169 -329
- package/src/backend/routes/posts/transformers.ts +61 -6
- package/src/backend/routes/recommendations.ts +37 -44
- package/src/backend/routes/rtc/index.ts +26 -6
- package/src/backend/routes/search.ts +39 -55
- package/src/backend/routes/stories/interactions.ts +22 -6
- package/src/backend/routes/stories/query-helpers.ts +28 -41
- package/src/backend/routes/stories/routes.ts +125 -108
- package/src/backend/routes/takos-tools/dm.ts +17 -17
- package/src/backend/routes/takos-tools/posts.ts +189 -106
- package/src/backend/routes/takos-tools/search.ts +13 -4
- package/src/backend/routes/takos-tools/timeline.ts +35 -27
- package/src/backend/routes/takos-tools-response.ts +6 -2
- package/src/backend/routes/timeline.ts +5 -5
- package/src/backend/runtime/bun.ts +1585 -99
- package/src/backend/runtime/call-signaling-do.ts +35 -4
- package/src/backend/runtime/cloudflare.ts +1 -1
- package/src/backend/runtime/managed-runtime.ts +1 -1
- package/src/backend/runtime/one-time-ticket.ts +116 -0
- package/src/backend/runtime/realtime-stream-do.ts +5 -61
- package/src/backend/runtime/signaling-hub.ts +36 -3
- package/src/backend/runtime/types.ts +1 -1
- package/src/backend/server.ts +95 -80
- package/src/db/d1-write.ts +67 -43
- package/src/db/schema/federation.ts +42 -1
- package/src/db/schema/messaging.ts +110 -2
- 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
|
+
"version": "3.4.5",
|
|
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",
|
|
@@ -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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
|
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
|
|
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>(
|
|
33
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|