Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ __generated__/

.pgdata/

mise.local.toml
node_modules/
packages/*/dist/

Expand Down
2 changes: 1 addition & 1 deletion .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"eslint/max-lines": "off",
"eslint/id-length": ["warn", { "exceptionPatterns": ["^_", "^[Tertv]$"] }],
"eslint/init-declarations": "off",
"eslint/max-params": ["warn", { "max": 4 }],
"eslint/max-params": "off",
"eslint/max-statements": ["warn", { "max": 20 }],
"eslint/no-console": "warn",
"eslint/no-use-before-define": "off",
Expand Down
7 changes: 6 additions & 1 deletion packages/graphql/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@
"./schema": {
"types": "./dist/schema.d.mts",
"default": "./dist/schema.mjs"
},
"./object": {
"types": "./dist/object.d.mts",
"default": "./dist/object.mjs"
}
},
"files": [
Expand All @@ -83,7 +87,8 @@
"src/builder.ts",
"src/federation.ts",
"src/instance.ts",
"src/schema.ts"
"src/schema.ts",
"src/object.ts"
],
"dts": {
"sourcemap": true,
Expand Down
152 changes: 45 additions & 107 deletions packages/graphql/src/actor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,23 @@

import assert from "node:assert/strict";

import { type Database, schema } from "@drfed/models";
import { schema } from "@drfed/models";
import { describe, it } from "@logtape/testing-node/autoload";
import { eq } from "drizzle-orm";

import { hashSecret } from "./auth/hash.ts";
import { withTestHarness } from "./harness.test.ts";

const accepted = new Date("2026-08-04T00:00:00.000Z");
const created = new Date("2026-08-04T00:00:00.000Z");
const expires = new Date("2030-08-04T00:00:00.000Z");
const ok = 200;

const accountId = "00000000-0000-4000-8000-000000000001";
const localInstanceId = "00000000-0000-4000-8000-000000000101";
const remoteInstanceId = "00000000-0000-4000-8000-000000000102";
const localActorId = "00000000-0000-4000-8000-000000000201";
const remoteActorId = "00000000-0000-4000-8000-000000000202";
const sessionId = "00000000-0000-4000-8000-000000000301";
const accessToken = "test-access-token";
import {
created,
globalId,
localActorId,
localInstanceId,
ok,
remoteActorId,
remoteInstanceId,
seedAuthenticatedLocalInstance,
seedLocalActor,
seedRemoteActor,
} from "./seed.test.ts";

const generateActorsMutation = `
mutation GenerateActors($instance: ID!, $size: Int!) {
Expand Down Expand Up @@ -239,97 +238,36 @@ describe("Actor", () => {
});
});
});
});

function globalId(type: "Actor" | "Instance", id: string): string {
return Buffer.from(`${type}:${id}`).toString("base64");
}

async function seedAuthenticatedLocalInstance(
db: Database,
): Promise<RequestInit> {
await db.insert(schema.accounts).values({
id: accountId,
email: "owner@example.com",
name: "Owner",
created,
});
await db.insert(schema.sessions).values({
id: sessionId,
accountId,
tokenHash: await hashSecret(accessToken),
});
await seedLocalInstance(db);
await db.insert(schema.instanceMembers).values({
accountId,
instanceId: localInstanceId,
admin: true,
accepted,
created,
});
return { headers: { authorization: `Bearer ${accessToken}` } };
}

async function seedLocalActor(db: Database): Promise<void> {
await seedLocalInstance(db);
await db.insert(schema.localActors).values({
id: localActorId,
avatar: "avatar.png",
header: "header.png",
});
await db.insert(schema.actors).values({
id: localActorId,
localId: localActorId,
instanceId: localInstanceId,
type: "Person",
username: "alice",
iri: `https://test-instance.drfed.org/users/${localActorId}`,
inboxUrl: `https://test-instance.drfed.org/users/${localActorId}/inbox`,
outboxUrl: `https://test-instance.drfed.org/users/${localActorId}/outbox`,
avatarUrl: `https://test-instance.drfed.org/users/${localActorId}/avatar/avatar.png`,
followersUrl: `https://test-instance.drfed.org/users/${localActorId}/followers`,
followingUrl: `https://test-instance.drfed.org/users/${localActorId}/following`,
headerUrl: `https://test-instance.drfed.org/users/${localActorId}/header/header.png`,
profileUrl: "https://test-instance.drfed.org/@alice",
featuredUrl: `https://test-instance.drfed.org/users/${localActorId}/featured`,
created,
});
}

async function seedLocalInstance(db: Database): Promise<void> {
await db.insert(schema.localInstances).values({
id: localInstanceId,
slug: "test-instance",
expires,
});
await db.insert(schema.instances).values({
id: localInstanceId,
localId: localInstanceId,
created,
host: "test-instance.drfed.org",
});
}

async function seedRemoteActor(db: Database): Promise<void> {
await db.insert(schema.instances).values({
id: remoteInstanceId,
created,
host: "remote.example.com",
});
await db.insert(schema.actors).values({
id: remoteActorId,
instanceId: remoteInstanceId,
type: "Service",
username: "bob",
iri: "https://remote.example.com/users/bob",
inboxUrl: "https://remote.example.com/users/bob/inbox",
outboxUrl: "https://remote.example.com/users/bob/outbox",
avatarUrl: "https://remote.example.com/users/bob/avatar.png",
followersUrl: "https://remote.example.com/users/bob/followers",
followingUrl: "https://remote.example.com/users/bob/following",
headerUrl: "https://remote.example.com/users/bob/header.png",
profileUrl: "https://remote.example.com/@bob",
featuredUrl: "https://remote.example.com/users/bob/featured",
created,
it("hides deleted actors from node and nodes while keeping live ones", async () => {
await withTestHarness(async ({ db, post }) => {
await seedLocalActor(db);
await seedRemoteActor(db);
await db
.update(schema.actors)
.set({ deleted: new Date() })
.where(eq(schema.actors.id, localActorId));
const query = `query($live: ID!, $deleted: ID!) {
live: node(id: $live) { ... on Actor { uuid instance { uuid } } }
deleted: node(id: $deleted) { ... on Actor { uuid instance { uuid } } }
nodes(ids: [$live, $deleted]) { ... on Actor { uuid } }
}`;
const body = await (
await post({
query,
variables: {
live: globalId("Actor", remoteActorId),
deleted: globalId("Actor", localActorId),
},
})
).json();
assert.deepEqual(body, {
data: {
live: { uuid: remoteActorId, instance: { uuid: remoteInstanceId } },
deleted: null,
nodes: [{ uuid: remoteActorId }, null],
},
});
});
});
}
});
18 changes: 18 additions & 0 deletions packages/graphql/src/builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,16 @@ export const builder = new SchemaBuilder<SchemaTypes>({
},
plugins: [DrizzlePlugin, RelayPlugin, ErrorsPlugin, ScopeAuthPlugin],
errors: { defaultTypes: [] },
relay: {
nodeQueryOptions: {
resolve: async (_, { id }, __, ___, resolveNode) =>
filterDeleted(await resolveNode(id)),
},
nodesQueryOptions: {
resolve: async (_, { ids }, __, ___, resolveNodes) =>
(await resolveNodes(ids)).map(filterDeleted),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filter hides deleted records only when they are the direct result of node or nodes. After marking an actor deleted, node(actorId) returns null, but node(objectId) { ... on Object { actor { uuid } } } still returns that actor. It also remains reachable through Instance.actors, and following its objects connection still returns content.

What should happen to an actor's objects when the actor is soft-deleted? The relation queries and their counts should follow that decision. The foreign key's ON DELETE CASCADE only handles physical deletion. Please add regression coverage for Object.actor, Instance.actors, and the deleted actor's objects connection alongside the direct node lookups.

},
},
scopeAuth: {
authorizeOnSubscribe: true,
authScopes(context) {
Expand All @@ -173,6 +183,14 @@ export const builder = new SchemaBuilder<SchemaTypes>({
},
});

const filterDeleted = (node: unknown): unknown =>
node != null &&
typeof node === "object" &&
"deleted" in node &&
node.deleted != null
? null
: node;

/**
* Determines whether the viewer is an accepted member of the `Instance` that
* the given `LocalInstance` backs. Pending members, i.e. those who have been
Expand Down
Loading