Skip to content

Commit a958091

Browse files
authored
Merge pull request #669 from tako0614/feature/TASK-0072-outgoing-follow-status
Expose viewer-scoped outgoing Follow status on Actor reads
2 parents 4ca00b7 + a2bf2ab commit a958091

7 files changed

Lines changed: 474 additions & 38 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,10 @@ public export をすべて import します。Core の `client-api` と独立し
6565
bundle、Core の Worker bundle も検査します。依存はインストール済みのローカル module を
6666
使うため registry への接続は不要です。公開後の registry readback は deploy の別検証です。
6767

68+
Actor GET の閲覧者別Follow状態は
69+
[outgoing Follow契約](docs/design/actor-outgoing-follow-status.md)を参照してください。
70+
未公開sourceの契約と、registry公開済みpackageのconsumer利用は別に検証します。
71+
6872
### core → product の release 順序
6973

7074
新しい API 契約 (例: ブラウザ通知の public API と `0019_notification_push_delivery.sql`、core / API `3.1.0` から)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Viewer-scoped outgoing Follow state
2+
3+
Authenticated `GET /api/actors/:identifier` returns
4+
`actor.outgoing_follow_status`: `none`, `pending`, or `accepted`. It describes
5+
the current authenticated actor's outgoing relationship to the resolved target,
6+
including cached remote profiles. Self reads return `none`; anonymous reads
7+
omit the field. The SDK preserves legacy omission and rejects a present value
8+
outside this enum. Consumers can restore an approval-pending control from a
9+
read rather than issuing a duplicate Follow mutation. Duplicate POST refusal
10+
is unchanged.
11+
12+
`is_following` and `is_followed_by` remain accepted-only. Remote profile reads
13+
now compute these booleans from the same viewer-scoped relationship instead of
14+
hardcoding false. Pending/absent/rejected legacy edges do not grant accepted
15+
authority or change counters or follower/following lists. Accepted wins if
16+
legacy cosmetic duplicates coexist; a read does not repair those stored rows.
17+
18+
For remote targets, match only the cosmetic Actor IRI equivalence already used
19+
by verified federation mutations: scheme/host case, default port, fragment,
20+
and one trailing slash. Path/query identity stays significant. SQL narrows to
21+
the indexed exact local viewer endpoint before applying the existing
22+
materialized identity matcher, with a fixed number of parameters. A remote
23+
cache recovery relationship belonging to another viewer never supplies this
24+
viewer state. Operator-blocked actors still fail their existing read gate.
25+
26+
Authenticated profile responses use `Cache-Control: private, no-store`; every
27+
profile response varies on Cookie and Authorization. The field is not added
28+
to generic summaries, search or follow-list projections. This reusable Core
29+
contract imposes no product ownership model and makes no schema transition.
30+
31+
Source/SDK and disposable native D1 qualification are separate from publication
32+
and actual consumer use. Both npm packages still await their owning paired
33+
immutable publication and registry readback; products must then adopt the
34+
published contract and pass their own gates. Operator-data and live federation
35+
qualification remain independent.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { expect, test } from "bun:test";
2+
3+
import { clearYurucommuApiTransport } from "../transport.ts";
4+
import { fetchActor } from "./actors.ts";
5+
import { InvalidApiResponseError } from "./response.ts";
6+
7+
function actor(outgoing_follow_status?: unknown) {
8+
return {
9+
ap_id: "https://example.com/ap/users/alice",
10+
preferred_username: "alice",
11+
...(outgoing_follow_status === undefined ? {} : { outgoing_follow_status }),
12+
};
13+
}
14+
15+
function outgoingStatus(value: object): unknown {
16+
return (value as Record<string, unknown>).outgoing_follow_status;
17+
}
18+
19+
async function withMockFetch<T>(
20+
body: unknown,
21+
fn: () => Promise<T>,
22+
): Promise<T> {
23+
const originalFetch = globalThis.fetch;
24+
clearYurucommuApiTransport();
25+
globalThis.fetch = Object.assign(
26+
async () =>
27+
new Response(JSON.stringify({ actor: body }), {
28+
status: 200,
29+
headers: { "Content-Type": "application/json" },
30+
}),
31+
{ preconnect: originalFetch.preconnect },
32+
);
33+
34+
try {
35+
return await fn();
36+
} finally {
37+
globalThis.fetch = originalFetch;
38+
clearYurucommuApiTransport();
39+
}
40+
}
41+
42+
test("fetchActor accepts legacy omission and preserves valid outgoing status", async () => {
43+
const legacy = await withMockFetch(actor(), () => fetchActor("alice"));
44+
expect(legacy).not.toHaveProperty("outgoing_follow_status");
45+
46+
for (const status of ["none", "pending", "accepted"] as const) {
47+
const result = await withMockFetch(actor(status), () =>
48+
fetchActor("alice"),
49+
);
50+
expect(outgoingStatus(result)).toBe(status);
51+
expect(result.username).toBe("alice@example.com");
52+
}
53+
});
54+
55+
test("fetchActor rejects malformed outgoing status values as invalid API responses", async () => {
56+
for (const status of [null, 1, false, "rejected", "unknown"]) {
57+
await expect(
58+
withMockFetch(actor(status), () => fetchActor("alice")),
59+
).rejects.toBeInstanceOf(InvalidApiResponseError);
60+
}
61+
});

‎packages/api/src/lib/api/response.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ export function validateActor(value: unknown): void {
103103
requiredString(actor.username, "actor.username");
104104
for (const key of ["preferred_username", "name", "icon_url"])
105105
optionalString(actor, key);
106+
if (
107+
actor.outgoing_follow_status !== undefined &&
108+
actor.outgoing_follow_status !== "none" &&
109+
actor.outgoing_follow_status !== "pending" &&
110+
actor.outgoing_follow_status !== "accepted"
111+
) {
112+
throw new InvalidApiResponseError("actor.outgoing_follow_status");
113+
}
106114
}
107115

108116
export function validatePost(value: unknown): void {

‎packages/api/src/types/index.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export interface Actor {
2323
created_at: string;
2424
is_following?: boolean;
2525
is_followed_by?: boolean;
26+
// Authenticated Actor GET only; legacy servers and anonymous reads omit it.
27+
// Pending requests never contribute to accepted-only booleans/counts/lists.
28+
outgoing_follow_status?: "none" | "pending" | "accepted";
2629
// Structured PropertyValue profile fields (Mastodon-parity, capped at 4 on
2730
// the backend). Rendered as a small label/value list on the profile.
2831
fields?: { name: string; value: string }[];

0 commit comments

Comments
 (0)