diff --git a/packages/graphql/src/account.test.ts b/packages/graphql/src/account.test.ts
index bc00e39..ec1b03a 100644
--- a/packages/graphql/src/account.test.ts
+++ b/packages/graphql/src/account.test.ts
@@ -30,6 +30,12 @@ const memberId = "00000000-0000-4000-8000-000000000002";
const pendingMemberId = "00000000-0000-4000-8000-000000000003";
const acceptedInstanceId = "00000000-0000-4000-8000-000000000101";
const pendingInstanceId = "00000000-0000-4000-8000-000000000102";
+const sessionId = "00000000-0000-4000-8000-000000000201";
+const accessToken = "test-access-token";
+const otherSessionId = "00000000-0000-4000-8000-000000000202";
+const otherAccessToken = "other-access-token";
+const siteAdminId = "00000000-0000-4000-8000-000000000005";
+const memberNodeId = btoa(`Account:${memberId}`);
const accountByUuidQuery = `
query Account($uuid: UUID!) {
@@ -41,6 +47,37 @@ const accountByUuidQuery = `
}
`;
+const accountNodeQuery = `
+ query AccountNode($id: ID!) {
+ node(id: $id) {
+ __typename
+ ... on Account {
+ uuid
+ name
+ email
+ admin
+ instances {
+ totalCount
+ }
+ }
+ }
+ }
+`;
+
+const accountPrivateFieldsQuery = `
+ query AccountPrivateFields($uuid: UUID!) {
+ accountByUuid(uuid: $uuid) {
+ uuid
+ name
+ email
+ admin
+ instances {
+ totalCount
+ }
+ }
+ }
+`;
+
const accountInstancesQuery = `
query AccountInstances($uuid: UUID!) {
accountByUuid(uuid: $uuid) {
@@ -93,6 +130,180 @@ const accountInstancesResponse = {
describe("accountByUuid", () => {
it("returns an account by UUID", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedAccounts(db);
+ const auth = await createSession(db);
+
+ const response = await post(
+ { query: accountByUuidQuery, variables: { uuid: accountId } },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), accountByUuidResponse);
+ });
+ });
+});
+
+describe("Account field authorization", () => {
+ it("hides another account's private fields from a logged-in viewer", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedAccounts(db);
+ // Authenticated as `accountId`, asking about `memberId`.
+ const auth = await createSession(db);
+
+ const response = await post(
+ { query: accountPrivateFieldsQuery, variables: { uuid: memberId } },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ // Public fields survive; each private field is individually null rather
+ // than nulling the whole `Account`.
+ assert.deepEqual(body.data.accountByUuid, {
+ uuid: memberId,
+ name: "Member",
+ email: null,
+ admin: null,
+ instances: null,
+ });
+ assert.deepEqual(
+ body.errors.map((e: { message: string }) => e.message).sort(),
+ [
+ "Not authorized to resolve Account.admin",
+ "Not authorized to resolve Account.email",
+ "Not authorized to resolve Account.instances",
+ ],
+ );
+ });
+ });
+
+ it("shows an account its own private fields", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedAccounts(db);
+ const auth = await createSession(db);
+
+ const response = await post(
+ { query: accountPrivateFieldsQuery, variables: { uuid: accountId } },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ accountByUuid: {
+ uuid: accountId,
+ name: "Owner",
+ email: "owner@example.com",
+ admin: false,
+ instances: { totalCount: 0 },
+ },
+ },
+ });
+ });
+ });
+
+ it("shows a site administrator another account's private fields", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedAccounts(db);
+ await db.insert(schema.accounts).values({
+ id: siteAdminId,
+ email: "admin@example.com",
+ name: "Site Admin",
+ admin: true,
+ created,
+ });
+ const auth = await createSession(db, {
+ id: otherSessionId,
+ account: siteAdminId,
+ token: otherAccessToken,
+ });
+
+ const response = await post(
+ { query: accountPrivateFieldsQuery, variables: { uuid: memberId } },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ accountByUuid: {
+ uuid: memberId,
+ name: "Member",
+ email: "member@example.com",
+ admin: false,
+ instances: { totalCount: 0 },
+ },
+ },
+ });
+ });
+ });
+
+ it("hides private fields reached through `node(id:)` from another account", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedAccounts(db);
+ const auth = await createSession(db);
+
+ const response = await post(
+ { query: accountNodeQuery, variables: { id: memberNodeId } },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.deepEqual(body.data.node, {
+ __typename: "Account",
+ uuid: memberId,
+ name: "Member",
+ email: null,
+ admin: null,
+ instances: null,
+ });
+ assert.deepEqual(
+ body.errors.map((e: { message: string }) => e.message).sort(),
+ [
+ "Not authorized to resolve Account.admin",
+ "Not authorized to resolve Account.email",
+ "Not authorized to resolve Account.instances",
+ ],
+ );
+ });
+ });
+
+ it("hides private fields reached through `node(id:)` from an anonymous viewer", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedAccounts(db);
+
+ // `node(id:)` carries no `authenticated` scope, unlike `accountByUuid`,
+ // so this is the entry path that the field scopes alone must hold.
+ const response = await post({
+ query: accountNodeQuery,
+ variables: { id: memberNodeId },
+ });
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.deepEqual(body.data.node, {
+ __typename: "Account",
+ uuid: memberId,
+ name: "Member",
+ email: null,
+ admin: null,
+ instances: null,
+ });
+ assert.deepEqual(
+ body.errors.map((e: { message: string }) => e.message).sort(),
+ [
+ "Not authorized to resolve Account.admin",
+ "Not authorized to resolve Account.email",
+ "Not authorized to resolve Account.instances",
+ ],
+ );
+ });
+ });
+
+ it("denies `accountByUuid` to an unauthenticated viewer", async () => {
await withTestHarness(async ({ db, post }) => {
await seedAccounts(db);
@@ -102,7 +313,12 @@ describe("accountByUuid", () => {
});
assert.equal(response.status, ok);
- assert.deepEqual(await response.json(), accountByUuidResponse);
+ const body = await response.json();
+ assert.equal(body.data.accountByUuid, null);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to resolve Query.accountByUuid",
+ );
});
});
});
@@ -111,11 +327,12 @@ describe("Account.instances", () => {
it("returns the account's accepted instances", async () => {
await withTestHarness(async ({ db, post }) => {
await seedMembershipGraph(db);
+ const auth = await createSession(db);
- const response = await post({
- query: accountInstancesQuery,
- variables: { uuid: accountId },
- });
+ const response = await post(
+ { query: accountInstancesQuery, variables: { uuid: accountId } },
+ auth,
+ );
assert.equal(response.status, ok);
assert.deepEqual(await response.json(), accountInstancesResponse);
@@ -123,6 +340,41 @@ describe("Account.instances", () => {
});
});
+/**
+ * Opens an authenticated session for an already-seeded account, then returns
+ * the request options carrying the session's bearer token.
+ *
+ * @param db The database to seed.
+ * @param options The session id, account id, and bearer token to use. Each
+ * defaults to {@link accountId}'s.
+ * @returns Request options with an `Authorization` header for {@link post}.
+ */
+async function createSession(
+ db: Database,
+ options: { id?: string; account?: string; token?: string } = {},
+): Promise {
+ const { id = sessionId, account = accountId, token = accessToken } = options;
+ await db.insert(schema.sessions).values({
+ id,
+ accountId: account,
+ tokenHash: await hashSecret(token),
+ });
+ return { headers: { authorization: `Bearer ${token}` } };
+}
+
+/**
+ * Computes the SHA-256 hex digest the server stores for a bearer token,
+ * mirroring `hashSecret` in *auth/hash.ts*.
+ *
+ * @param raw The raw access token.
+ * @returns The lowercase hex-encoded SHA-256 digest.
+ */
+async function hashSecret(raw: string): Promise {
+ return new Uint8Array(
+ await crypto.subtle.digest("SHA-256", new TextEncoder().encode(raw)),
+ ).toHex();
+}
+
async function seedAccounts(db: Database): Promise {
await db.insert(schema.accounts).values([
{
diff --git a/packages/graphql/src/account.ts b/packages/graphql/src/account.ts
index f07b118..d6ec689 100644
--- a/packages/graphql/src/account.ts
+++ b/packages/graphql/src/account.ts
@@ -21,6 +21,26 @@ import builder, { type DrFedObjectRef } from "./builder.ts";
// oxlint-disable-next-line import/no-cycle
import { Instance } from "./instance.ts";
+/**
+ * The auth scopes for `Account` fields that only the `Account` itself and
+ * site administrators may read.
+ *
+ * @param account The `Account` the field belongs to.
+ * @returns A scope map granting access to the account itself or a site
+ * administrator.
+ */
+function selfOrSiteAdmin(account: { readonly id: string }) {
+ return {
+ $any: {
+ admin: true,
+ accountSelf: account.id,
+ // Granted by `Session.account`, whose account is always the viewer's own
+ // but which is resolved on a request that carries no session yet.
+ $granted: "ownAccount",
+ },
+ };
+}
+
const AccountRef = builder.drizzleNode("accounts", {
name: "Account",
description:
@@ -39,13 +59,21 @@ const AccountRef = builder.drizzleNode("accounts", {
}),
email: t.expose("email", {
type: "Email",
- description: "The email address of the `Account`.",
+ nullable: true,
+ description:
+ "The email address of the `Account`. `null` unless the viewer is " +
+ "the `Account` itself or a site administrator.",
+ authScopes: selfOrSiteAdmin,
}),
name: t.exposeString("name", {
description: "The display name of the `Account`.",
}),
admin: t.exposeBoolean("admin", {
- description: "Whether the `Account` has administrator privileges.",
+ nullable: true,
+ description:
+ "Whether the `Account` has administrator privileges. `null` unless " +
+ "the viewer is the `Account` itself or a site administrator.",
+ authScopes: selfOrSiteAdmin,
}),
created: t.expose("created", {
type: "DateTime",
@@ -83,7 +111,12 @@ builder.drizzleObjectField(AccountRef, "instances", (t) =>
t.connection(
{
type: Instance,
- description: "The `Instance`s that the `Account` belongs to.",
+ nullable: true,
+ description:
+ "The `Instance`s that the `Account` belongs to. `null` unless the " +
+ "viewer is the `Account` itself or a site administrator; an empty " +
+ "connection means the `Account` belongs to no `Instance`.",
+ authScopes: selfOrSiteAdmin,
select(args, ctx, nestedSelection) {
return {
with: {
@@ -164,6 +197,7 @@ builder.queryFields((t) => ({
},
description: "Get an `Account` by its UUID.",
nullable: true,
+ authScopes: { authenticated: true },
resolve(query, _, { uuid }, ctx) {
return ctx.db.query.accounts.findFirst(query({ where: { id: uuid } }));
},
diff --git a/packages/graphql/src/auth.test.ts b/packages/graphql/src/auth.test.ts
index fc5dd4f..2ae1bbd 100644
--- a/packages/graphql/src/auth.test.ts
+++ b/packages/graphql/src/auth.test.ts
@@ -21,12 +21,15 @@ import { deepEqual, equal, ok } from "node:assert/strict";
import { schema } from "@drfed/models";
import { describe, it } from "@logtape/testing-node/autoload";
-import { withTestHarness } from "./harness.test.ts";
+import { type TestHarness, withTestHarness } from "./harness.test.ts";
const okStatus = 200;
const accountId = "00000000-0000-4000-8000-000000000001";
const email = "noreply@drfed.org";
const verifyUrl = "https://drfed.org/transports/mock?token={token}&code={code}";
+const memberId = "00000000-0000-4000-8000-000000000002";
+const memberEmail = "member@example.com";
+const instanceId = "00000000-0000-4000-8000-000000000101";
const loginMutation = `
mutation Login($email: Email!, $verifyUrl: URITemplate) {
@@ -51,6 +54,31 @@ const completeLoginMutation = `
}
`;
+const completeLoginReachingOthersMutation = `
+ mutation CompleteLoginReachingOthers($token: UUID!, $code: String!) {
+ completeLoginChallenge(token: $token, code: $code) {
+ account {
+ uuid
+ email
+ instances {
+ edges {
+ node {
+ members {
+ edges {
+ node {
+ uuid
+ email
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
const viewerQuery = `
query Viewer {
viewer {
@@ -71,7 +99,82 @@ const revokeSessionMutation = `
const loginUrlPattern =
/https:\/\/drfed\.org\/transports\/mock\?token=[0-9a-f-]+&code=[0-9a-z]+/u;
+/**
+ * Runs the login-by-email flow far enough to obtain a challenge token and the
+ * one-time code mailed with it.
+ *
+ * @param post The harness request helper.
+ * @param mailer The harness mock mailer.
+ * @returns The challenge token and its one-time code.
+ */
+async function requestLoginCode(
+ post: TestHarness["post"],
+ mailer: TestHarness["mailer"],
+): Promise<{ token: string; code: string }> {
+ const loginResponse = await post({
+ query: loginMutation,
+ variables: { email, verifyUrl },
+ });
+ equal(loginResponse.status, okStatus);
+ const loginBody = await loginResponse.json();
+ equal(loginBody.errors, undefined);
+ const { token } = loginBody.data.loginByEmail;
+
+ const [message] = mailer.getSentMessages();
+ ok(message);
+ const urlMatch = message.content.text?.match(loginUrlPattern);
+ ok(urlMatch);
+ const code = new URL(urlMatch[0]).searchParams.get("code");
+ ok(code);
+ return { token, code };
+}
+
describe("email authentication", () => {
+ it("does not let the login grant reach another account's email", async () => {
+ await withTestHarness(async ({ db, mailer, post }) => {
+ await db.insert(schema.accounts).values([
+ { id: accountId, email, name: "Login Test" },
+ { id: memberId, email: memberEmail, name: "Fellow Member" },
+ ]);
+ await db
+ .insert(schema.instances)
+ .values({ id: instanceId, host: "shared.example.com" });
+ await db.insert(schema.instanceMembers).values([
+ { accountId, instanceId, accepted: new Date() },
+ { accountId: memberId, instanceId, accepted: new Date() },
+ ]);
+
+ const { token, code } = await requestLoginCode(post, mailer);
+
+ // `Session.account` grants `ownAccount`, but the grant must cover only
+ // the viewer's own account -- not every account reachable underneath it.
+ // This request carries no Authorization header at all.
+ const response = await post({
+ query: completeLoginReachingOthersMutation,
+ variables: { token, code },
+ });
+
+ equal(response.status, okStatus);
+ const body = await response.json();
+ const { account } = body.data.completeLoginChallenge;
+ equal(account.uuid, accountId);
+ equal(account.email, email);
+
+ const members = account.instances.edges[0].node.members.edges;
+ const fellow = members.find(
+ (edge: { node: { uuid: string } }) => edge.node.uuid === memberId,
+ );
+ ok(fellow);
+ equal(fellow.node.email, null);
+ ok(
+ body.errors.some(
+ (e: { message: string }) =>
+ e.message === "Not authorized to resolve Account.email",
+ ),
+ );
+ });
+ });
+
it("logs in, authenticates the viewer, and revokes the session", async () => {
await withTestHarness(async ({ db, mailer, post }) => {
await db.insert(schema.accounts).values({
diff --git a/packages/graphql/src/auth/challenge.ts b/packages/graphql/src/auth/challenge.ts
index b953dbd..34638e2 100644
--- a/packages/graphql/src/auth/challenge.ts
+++ b/packages/graphql/src/auth/challenge.ts
@@ -36,7 +36,12 @@ const SessionRef = builder.drizzleObject("sessions", {
return typeof accessToken === "string" ? accessToken : null;
},
}),
- account: t.relation("account"),
+ // The only `Session` a client can obtain is the one `completeLoginChallenge`
+ // just created for it, and `Session` is not a `Node`, so the account behind
+ // a session is always the viewer's own. Granting `ownAccount` here lets
+ // the login response carry the viewer's private fields even though the
+ // request that produced it had no session yet.
+ account: t.relation("account", { grantScopes: ["ownAccount"] }),
created: t.expose("created", { type: "DateTime" }),
expires: t.expose("expires", { type: "DateTime" }),
}),
diff --git a/packages/graphql/src/builder.ts b/packages/graphql/src/builder.ts
index d6925cc..d13edbf 100644
--- a/packages/graphql/src/builder.ts
+++ b/packages/graphql/src/builder.ts
@@ -15,7 +15,12 @@
// along with this program. If not, see .
import { type Database, normalizeEmail, relations } from "@drfed/models";
-import type { Account, Session } from "@drfed/models/schema";
+import {
+ type Account,
+ type Session,
+ instanceMembers,
+ instances,
+} from "@drfed/models/schema";
import { Template } from "@fedify/uri-template";
import SchemaBuilder, { type ObjectRef } from "@pothos/core";
import DrizzlePlugin from "@pothos/plugin-drizzle";
@@ -24,6 +29,7 @@ import RelayPlugin from "@pothos/plugin-relay";
import ScopeAuthPlugin from "@pothos/plugin-scope-auth";
import type { Transport } from "@upyo/core";
import { getTableConfig } from "drizzle-orm/pg-core";
+import { and, eq, isNotNull } from "drizzle-orm/sql/expressions";
import { DateTimeResolver, UUIDResolver } from "graphql-scalars";
/**
@@ -104,6 +110,15 @@ export interface SchemaTypes {
AuthScopes: {
authenticated: boolean;
admin: boolean;
+ /**
+ * Whether the viewer is an accepted member of the `Instance` backed by
+ * the `LocalInstance` with the given UUID.
+ */
+ localInstanceMember: string;
+ /**
+ * Whether the viewer is the `Account` with the given UUID.
+ */
+ accountSelf: string;
};
}
@@ -136,11 +151,50 @@ export const builder = new SchemaBuilder({
return {
authenticated: Boolean(context.session),
admin: Boolean(context.account?.admin),
+ localInstanceMember(localInstanceId) {
+ return isLocalInstanceMember(context, localInstanceId);
+ },
+ accountSelf(accountId) {
+ return context.account?.id === accountId;
+ },
};
},
},
});
+/**
+ * Determines whether the viewer is an accepted member of the `Instance` that
+ * the given `LocalInstance` backs. Pending members, i.e. those who have been
+ * invited but have not accepted yet, do not count.
+ *
+ * Pothos caches scope results per request by scope name and parameter, so this
+ * runs at most once per `LocalInstance` per request.
+ *
+ * @param context The request context, whose `account` is the viewer.
+ * @param localInstanceId The UUID of the `LocalInstance` to check.
+ * @returns Whether the viewer is an accepted member.
+ */
+async function isLocalInstanceMember(
+ context: UserContext,
+ localInstanceId: string,
+): Promise {
+ const { account } = context;
+ if (account == null) return false;
+ const rows = await context.db
+ .select({ instanceId: instanceMembers.instanceId })
+ .from(instanceMembers)
+ .innerJoin(instances, eq(instanceMembers.instanceId, instances.id))
+ .where(
+ and(
+ eq(instances.localId, localInstanceId),
+ eq(instanceMembers.accountId, account.id),
+ isNotNull(instanceMembers.accepted),
+ ),
+ )
+ .limit(1);
+ return rows.length > 0;
+}
+
builder.addScalarType("DateTime", DateTimeResolver);
builder.scalarType("Email", {
diff --git a/packages/graphql/src/instance.test.ts b/packages/graphql/src/instance.test.ts
index cd092c4..59977d3 100644
--- a/packages/graphql/src/instance.test.ts
+++ b/packages/graphql/src/instance.test.ts
@@ -27,14 +27,24 @@ const accepted = new Date("2026-06-24T00:00:00.000Z");
const created = new Date("2026-06-24T00:00:00.000Z");
const expires = new Date("2026-07-24T00:00:00.000Z");
const ok = 200;
+const defaultMaxActors = 10;
const accountId = "00000000-0000-4000-8000-000000000001";
const memberId = "00000000-0000-4000-8000-000000000002";
const pendingMemberId = "00000000-0000-4000-8000-000000000003";
const instanceId = "00000000-0000-4000-8000-000000000101";
+const localInstanceId = "00000000-0000-4000-8000-000000000103";
+const otherInstanceId = "00000000-0000-4000-8000-000000000104";
+const otherLocalInstanceId = "00000000-0000-4000-8000-000000000105";
+const localInstanceNodeId = btoa(`LocalInstance:${localInstanceId}`);
+const instanceNodeId = btoa(`Instance:${instanceId}`);
const duplicateRemoteInstanceId = "00000000-0000-4000-8000-000000000102";
const sessionId = "00000000-0000-4000-8000-000000000201";
+const otherSessionId = "00000000-0000-4000-8000-000000000202";
+const strangerId = "00000000-0000-4000-8000-000000000004";
+const siteAdminId = "00000000-0000-4000-8000-000000000005";
const accessToken = "test-access-token";
+const otherAccessToken = "other-access-token";
const remoteInstanceQuery = `
query RemoteInstance($uuid: UUID!) {
@@ -51,6 +61,82 @@ const remoteInstanceQuery = `
}
`;
+const localInstanceQuery = `
+ query LocalInstance($uuid: UUID!) {
+ accountByUuid(uuid: $uuid) {
+ instances {
+ edges {
+ node {
+ host
+ localInstance {
+ uuid
+ slug
+ expires
+ maxActors
+ }
+ }
+ }
+ }
+ }
+ }
+`;
+
+const localInstanceBySlugQuery = `
+ query LocalInstanceBySlug($slug: String!) {
+ localInstanceBySlug(slug: $slug) {
+ uuid
+ slug
+ expires
+ maxActors
+ instance {
+ uuid
+ host
+ }
+ }
+ }
+`;
+
+const localInstanceNodeQuery = `
+ query LocalInstanceNode($id: ID!) {
+ node(id: $id) {
+ __typename
+ ... on LocalInstance {
+ uuid
+ slug
+ }
+ }
+ }
+`;
+
+const instanceNodeQuery = `
+ query InstanceNode($id: ID!) {
+ node(id: $id) {
+ ... on Instance {
+ host
+ localInstance {
+ slug
+ }
+ }
+ }
+ }
+`;
+
+const localInstanceNodesQuery = `
+ query LocalInstanceNodes($ids: [ID!]!) {
+ nodes(ids: $ids) {
+ __typename
+ }
+ }
+`;
+
+const localInstanceTypenameQuery = `
+ query LocalInstanceTypename($id: ID!) {
+ node(id: $id) {
+ __typename
+ }
+ }
+`;
+
const instanceMembersQuery = `
query InstanceMembers($uuid: UUID!) {
accountByUuid(uuid: $uuid) {
@@ -103,7 +189,7 @@ const instanceMembersResponse = {
admin: false,
node: {
uuid: memberId,
- email: "member@example.com",
+ email: null,
name: "Member",
},
},
@@ -121,14 +207,23 @@ describe("Instance.members", () => {
it("returns the instance's accepted members", async () => {
await withTestHarness(async ({ db, post }) => {
await seedInstanceMembers(db);
+ const auth = await createSession(db);
- const response = await post({
- query: instanceMembersQuery,
- variables: { uuid: accountId },
- });
+ const response = await post(
+ { query: instanceMembersQuery, variables: { uuid: accountId } },
+ auth,
+ );
assert.equal(response.status, ok);
- assert.deepEqual(await response.json(), instanceMembersResponse);
+ const body = await response.json();
+ // The viewer reads their own email but not a fellow member's; the
+ // authorization error is confined to that one field.
+ assert.deepEqual(body.data, instanceMembersResponse.data);
+ assert.equal(body.errors.length, 1);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to resolve Account.email",
+ );
});
});
});
@@ -249,16 +344,480 @@ describe("Mutation.createInstance", () => {
});
});
-describe("Remote instance", () => {
- it("returns a created remote instance", async () => {
+describe("Instance.localInstance", () => {
+ it("returns the `LocalInstance` backing a local `Instance`", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+ const auth = await createSession(db);
+
+ const response = await post(
+ { query: localInstanceQuery, variables: { uuid: accountId } },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ accountByUuid: {
+ instances: {
+ edges: [
+ {
+ node: {
+ host: "test-instance.drfed.org",
+ localInstance: {
+ uuid: localInstanceId,
+ slug: "test-instance",
+ expires: expires.toISOString(),
+ maxActors: defaultMaxActors,
+ },
+ },
+ },
+ ],
+ },
+ },
+ },
+ });
+ });
+ });
+
+ it("returns null for a remote `Instance`", async () => {
await withTestHarness(async ({ db, post }) => {
await seedRemoteInstance(db);
+ const auth = await createSession(db);
+ const response = await post(
+ { query: localInstanceQuery, variables: { uuid: accountId } },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ accountByUuid: {
+ instances: {
+ edges: [
+ {
+ node: {
+ host: "remote.example.com",
+ localInstance: null,
+ },
+ },
+ ],
+ },
+ },
+ },
+ });
+ });
+ });
+});
+
+describe("Query.localInstanceBySlug", () => {
+ it("returns the `LocalInstance` with the given slug", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+ const auth = await createSession(db);
+
+ const response = await post(
+ {
+ query: localInstanceBySlugQuery,
+ variables: { slug: "test-instance" },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ localInstanceBySlug: {
+ uuid: localInstanceId,
+ slug: "test-instance",
+ expires: expires.toISOString(),
+ maxActors: defaultMaxActors,
+ instance: {
+ uuid: instanceId,
+ host: "test-instance.drfed.org",
+ },
+ },
+ },
+ });
+ });
+ });
+
+ it("returns null when no `LocalInstance` has the slug", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+ const auth = await createSession(db);
+
+ const response = await post(
+ {
+ query: localInstanceBySlugQuery,
+ variables: { slug: "no-such-instance" },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: { localInstanceBySlug: null },
+ });
+ });
+ });
+
+ it("denies the field to an unauthenticated viewer", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+
+ const response = await post({
+ query: localInstanceBySlugQuery,
+ variables: { slug: "test-instance" },
+ });
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.equal(body.data.localInstanceBySlug, null);
+ assert.equal(body.errors.length, 1);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to resolve Query.localInstanceBySlug",
+ );
+ assert.deepEqual(body.errors[0].path, ["localInstanceBySlug"]);
+ });
+ });
+});
+
+describe("LocalInstance authorization", () => {
+ it("denies `Instance.localInstance` to an unauthenticated viewer", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+
+ // `Instance` carries no scope, so an anonymous viewer still reaches it
+ // through `node(id:)`; only `localInstance` is denied.
const response = await post({
- query: remoteInstanceQuery,
- variables: { uuid: accountId },
+ query: instanceNodeQuery,
+ variables: { id: instanceNodeId },
});
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ // The field is nullable, so it absorbs the error and the rest of the
+ // `Instance` still resolves.
+ assert.equal(body.data.node.localInstance, null);
+ assert.equal(body.data.node.host, "test-instance.drfed.org");
+ assert.equal(body.errors.length, 1);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to read fields for LocalInstance",
+ );
+ });
+ });
+
+ it("denies `node(id:)` on a `LocalInstance` to an unauthenticated viewer", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+
+ const response = await post({
+ query: localInstanceNodeQuery,
+ variables: { id: localInstanceNodeId },
+ });
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.equal(body.data.node, null);
+ assert.equal(body.errors.length, 1);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to read fields for LocalInstance",
+ );
+ });
+ });
+
+ it("hides a `LocalInstance` from an unauthenticated `__typename` probe", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+
+ const response = await post({
+ query: localInstanceTypenameQuery,
+ variables: { id: localInstanceNodeId },
+ });
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ // `runScopesOnType` is what keeps `__typename` out of the response: a
+ // selection of only `__typename` touches no scoped field, so without it
+ // `data.node.__typename` would come back as `"LocalInstance"`. The
+ // error below still reveals that the ID resolves to something; only the
+ // data channel is closed here.
+ assert.equal(body.data.node, null);
+ assert.equal(body.errors.length, 1);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to read fields for LocalInstance",
+ );
+ });
+ });
+
+ it("denies a logged-in viewer who is not a member", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+ await db.insert(schema.accounts).values({
+ id: strangerId,
+ email: "stranger@example.com",
+ name: "Stranger",
+ created,
+ });
+ const auth = await createSession(db, {
+ id: otherSessionId,
+ account: strangerId,
+ token: otherAccessToken,
+ });
+
+ const response = await post(
+ {
+ query: localInstanceNodeQuery,
+ variables: { id: localInstanceNodeId },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.equal(body.data.node, null);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to read fields for LocalInstance",
+ );
+ });
+ });
+
+ it("denies a member who has not accepted the invitation yet", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ // `seedInstanceMembers` seeds this account with `accepted: null`.
+ await seedInstanceMembers(db);
+ const auth = await createSession(db, {
+ id: otherSessionId,
+ account: pendingMemberId,
+ token: otherAccessToken,
+ });
+
+ const response = await post(
+ {
+ query: localInstanceNodeQuery,
+ variables: { id: localInstanceNodeId },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.equal(body.data.node, null);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to read fields for LocalInstance",
+ );
+ });
+ });
+
+ it("allows a site administrator who is not a member", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+ await db.insert(schema.accounts).values({
+ id: siteAdminId,
+ email: "admin@example.com",
+ name: "Site Admin",
+ admin: true,
+ created,
+ });
+ const auth = await createSession(db, {
+ id: otherSessionId,
+ account: siteAdminId,
+ token: otherAccessToken,
+ });
+
+ const response = await post(
+ {
+ query: localInstanceNodeQuery,
+ variables: { id: localInstanceNodeId },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ node: {
+ __typename: "LocalInstance",
+ uuid: localInstanceId,
+ slug: "test-instance",
+ },
+ },
+ });
+ });
+ });
+
+ it("denies `nodes(ids:)` to a logged-in non-member", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+ await db.insert(schema.accounts).values({
+ id: strangerId,
+ email: "stranger@example.com",
+ name: "Stranger",
+ created,
+ });
+ const auth = await createSession(db, {
+ id: otherSessionId,
+ account: strangerId,
+ token: otherAccessToken,
+ });
+
+ const response = await post(
+ {
+ query: localInstanceNodesQuery,
+ variables: { ids: [localInstanceNodeId] },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.deepEqual(body.data.nodes, [null]);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to read fields for LocalInstance",
+ );
+ });
+ });
+
+ it("denies `localInstanceBySlug` to a logged-in non-member", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+ await db.insert(schema.accounts).values({
+ id: strangerId,
+ email: "stranger@example.com",
+ name: "Stranger",
+ created,
+ });
+ const auth = await createSession(db, {
+ id: otherSessionId,
+ account: strangerId,
+ token: otherAccessToken,
+ });
+
+ // The field's own `authenticated` scope passes here, so this exercises
+ // the type scope rather than the pre-resolver one.
+ const response = await post(
+ {
+ query: localInstanceBySlugQuery,
+ variables: { slug: "test-instance" },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.equal(body.data.localInstanceBySlug, null);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to read fields for LocalInstance",
+ );
+ });
+ });
+
+ it("denies a viewer who is a member of a different `Instance`", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedInstanceMembers(db);
+ await db.insert(schema.accounts).values({
+ id: strangerId,
+ email: "stranger@example.com",
+ name: "Stranger",
+ created,
+ });
+ // An accepted membership somewhere else must not grant access here; this
+ // is what pins the `instances.localId` condition in the predicate.
+ await db.insert(schema.localInstances).values({
+ id: otherLocalInstanceId,
+ slug: "other-instance",
+ expires,
+ });
+ await db.insert(schema.instances).values({
+ id: otherInstanceId,
+ localId: otherLocalInstanceId,
+ created,
+ host: "other-instance.drfed.org",
+ });
+ await db.insert(schema.instanceMembers).values({
+ accountId: strangerId,
+ instanceId: otherInstanceId,
+ accepted,
+ created,
+ });
+ const auth = await createSession(db, {
+ id: otherSessionId,
+ account: strangerId,
+ token: otherAccessToken,
+ });
+
+ const response = await post(
+ {
+ query: localInstanceNodeQuery,
+ variables: { id: localInstanceNodeId },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ const body = await response.json();
+ assert.equal(body.data.node, null);
+ assert.equal(
+ body.errors[0].message,
+ "Not authorized to read fields for LocalInstance",
+ );
+ });
+ });
+
+ it("allows an accepted member of the backing `Instance`", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ // `memberId` is an accepted member with `admin: false`, unlike the owner
+ // `accountId`, so this cannot pass by way of the per-instance admin flag.
+ await seedInstanceMembers(db);
+ const auth = await createSession(db, {
+ id: otherSessionId,
+ account: memberId,
+ token: otherAccessToken,
+ });
+
+ const response = await post(
+ {
+ query: localInstanceNodeQuery,
+ variables: { id: localInstanceNodeId },
+ },
+ auth,
+ );
+
+ assert.equal(response.status, ok);
+ assert.deepEqual(await response.json(), {
+ data: {
+ node: {
+ __typename: "LocalInstance",
+ uuid: localInstanceId,
+ slug: "test-instance",
+ },
+ },
+ });
+ });
+ });
+});
+
+describe("Remote instance", () => {
+ it("returns a created remote instance", async () => {
+ await withTestHarness(async ({ db, post }) => {
+ await seedRemoteInstance(db);
+ const auth = await createSession(db);
+
+ const response = await post(
+ { query: remoteInstanceQuery, variables: { uuid: accountId } },
+ auth,
+ );
+
assert.equal(response.status, ok);
assert.deepEqual(await response.json(), {
data: {
@@ -318,12 +877,31 @@ async function authenticate(
...(maxInstances == null ? {} : { maxInstances }),
created,
});
+ return createSession(db);
+}
+
+/**
+ * Opens an authenticated session for an already-seeded account, then returns
+ * the request options carrying the session's bearer token. Use this instead
+ * of {@link authenticate} when the account already exists, e.g. after
+ * {@link seedInstanceMembers}.
+ *
+ * @param db The database to seed.
+ * @param options The session id, account id, and bearer token to use. Each
+ * defaults to the instance owner's.
+ * @returns Request options with an `Authorization` header for {@link post}.
+ */
+async function createSession(
+ db: Database,
+ options: { id?: string; account?: string; token?: string } = {},
+): Promise {
+ const { id = sessionId, account = accountId, token = accessToken } = options;
await db.insert(schema.sessions).values({
- id: sessionId,
- accountId,
- tokenHash: await hashSecret(accessToken),
+ id,
+ accountId: account,
+ tokenHash: await hashSecret(token),
});
- return { headers: { authorization: `Bearer ${accessToken}` } };
+ return { headers: { authorization: `Bearer ${token}` } };
}
/**
@@ -364,13 +942,13 @@ async function seedInstanceMembers(db: Database): Promise {
},
]);
await db.insert(schema.localInstances).values({
- id: instanceId,
+ id: localInstanceId,
slug: "test-instance",
expires,
});
await db.insert(schema.instances).values({
id: instanceId,
- localId: instanceId,
+ localId: localInstanceId,
created,
host: "test-instance.drfed.org",
});
diff --git a/packages/graphql/src/instance.ts b/packages/graphql/src/instance.ts
index d5a2d05..73321d3 100644
--- a/packages/graphql/src/instance.ts
+++ b/packages/graphql/src/instance.ts
@@ -59,17 +59,38 @@ export const Instance: DrFedObjectRef = InstanceRef;
const LocalInstanceRef = builder.drizzleNode("localInstances", {
name: "LocalInstance",
- description: "Represents an `Instance` in the DrFed platform.",
+ description:
+ "Represents a `LocalInstance`, i.e., an `Instance` hosted by this DrFed " +
+ "deployment. Only accepted members of the `Instance` it backs, and " +
+ "site administrators, can read it, because it carries operational " +
+ "details such as the expiry date and the actor quota.",
+ authScopes(localInstance) {
+ return {
+ $any: {
+ admin: true,
+ localInstanceMember: localInstance.id,
+ },
+ };
+ },
+ // Check the scopes on the type itself rather than only on each field.
+ // Without this, a selection that touches no scoped field still resolves, so
+ // `node(id: $id) { __typename }` would answer `"LocalInstance"` to a viewer
+ // who may not read one. Note this closes the data channel only: the
+ // presence of the authorization error still tells such a viewer that the ID
+ // resolves to an existing `LocalInstance`, since an unknown ID yields a
+ // plain `null` with no error. That residual signal is accepted, as it is
+ // for slugs, and it additionally requires already knowing a UUID.
+ runScopesOnType: true,
id: {
column(instance) {
return instance.id;
},
- description: "The unique identifier of the `Instance`.",
+ description: "The unique identifier of the `LocalInstance`.",
},
fields: (t) => ({
uuid: t.expose("id", {
type: "UUID",
- description: "The UUID of the `Instance`.",
+ description: "The UUID of the `LocalInstance`.",
}),
slug: t.exposeString("slug"),
expires: t.expose("expires", {
@@ -82,6 +103,25 @@ const LocalInstanceRef = builder.drizzleNode("localInstances", {
export const LocalInstance: DrFedObjectRef = LocalInstanceRef;
+builder.drizzleObjectField(InstanceRef, "localInstance", (t) =>
+ t.relation("localInstance", {
+ nullable: true,
+ description:
+ "The `LocalInstance` backing the `Instance` when it is hosted by " +
+ "this DrFed deployment. `null` if the `Instance` is remote, i.e., " +
+ "hosted by another server on the fediverse.",
+ }),
+);
+
+builder.drizzleObjectField(LocalInstanceRef, "instance", (t) =>
+ t.relation("instance", {
+ nullable: true,
+ description:
+ "The `Instance` this `LocalInstance` backs, which carries the " +
+ "federation-facing data such as the host name.",
+ }),
+);
+
const instanceMembersConnection = drizzleConnectionHelpers(
builder,
"instanceMembers",
@@ -179,6 +219,29 @@ builder.drizzleObjectField(InstanceRef, "members", (t) =>
),
);
+builder.queryFields((t) => ({
+ localInstanceBySlug: t.drizzleField({
+ type: LocalInstanceRef,
+ nullable: true,
+ description:
+ "Get a `LocalInstance` by its slug. Returns `null` if no " +
+ "`LocalInstance` has the given slug.",
+ authScopes: { authenticated: true },
+ args: {
+ slug: t.arg({
+ type: "String",
+ required: true,
+ description:
+ "The slug of the `LocalInstance` to retrieve, i.e., the label " +
+ "that forms the first part of its host name.",
+ }),
+ },
+ resolve(query, _root, { slug }, ctx) {
+ return ctx.db.query.localInstances.findFirst(query({ where: { slug } }));
+ },
+ }),
+}));
+
export const CreateInstanceErrorType = builder.enumType(
"CreateInstanceErrorType",
{
diff --git a/packages/models/src/relations.ts b/packages/models/src/relations.ts
index 299db4c..1b8a8f3 100644
--- a/packages/models/src/relations.ts
+++ b/packages/models/src/relations.ts
@@ -72,13 +72,13 @@ export const relations = defineRelations(schema, (r) => ({
accepted: { isNotNull: true },
},
}),
- localInstances: r.one.localInstances({
+ localInstance: r.one.localInstances({
from: r.instances.localId,
to: r.localInstances.id,
}),
},
- localInstance: {
- instances: r.one.instances({
+ localInstances: {
+ instance: r.one.instances({
from: r.localInstances.id,
to: r.instances.localId,
}),
diff --git a/packages/web/src/routes/workspace/index.tsx b/packages/web/src/routes/workspace/index.tsx
index 9dc94b3..0fe150b 100644
--- a/packages/web/src/routes/workspace/index.tsx
+++ b/packages/web/src/routes/workspace/index.tsx
@@ -61,7 +61,7 @@ export default function WorkspacePage() {
}>
{(viewer) => (
-
+
{(edge) => }
)}