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),
},
},
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