Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
178 changes: 178 additions & 0 deletions graphql/account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,31 @@ test("addAccountMigrationAlias returns typed errors", async () => {
"NotAuthorizedError",
);

// A well-formed but nonexistent account id is also rejected with
// NotAuthorizedError, so the error does not leak whether the id exists.
const missing = await execute({
schema,
document: addAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID(
"Account",
"00000000-0000-4000-8000-000000000000",
),
actor: "@old@remote.example",
},
},
contextValue: makeUserContext(tx, owner.account),
onError: "NO_PROPAGATE",
});
assert.equal(missing.errors, undefined);
assert.equal(
(toPlainJson(missing.data) as {
addAccountMigrationAlias?: { __typename?: string };
}).addAccountMigrationAlias?.__typename,
"NotAuthorizedError",
);

const invalid = await execute({
schema,
document: addAccountMigrationAliasMutation,
Expand Down Expand Up @@ -1778,6 +1803,159 @@ test("removeAccountMigrationAlias removes one alias idempotently", async () => {
});
});

test("account migration aliases are manageable by organization admins", async () => {
await withRollback(async (tx) => {
const admin = await insertAccountWithActor(tx, {
username: "orgmigadmin",
name: "Org Migration Admin",
email: "orgmigadmin@example.com",
});
const member = await insertAccountWithActor(tx, {
username: "orgmigmember",
name: "Org Migration Member",
email: "orgmigmember@example.com",
});
const outsider = await insertAccountWithActor(tx, {
username: "orgmigoutsider",
name: "Org Migration Outsider",
email: "orgmigoutsider@example.com",
});
const organization = await insertAccountWithActor(tx, {
username: "orgmigration",
name: "Org Migration",
email: "orgmigration@example.com",
kind: "organization",
type: "Organization",
});
await tx.insert(organizationMembershipTable).values([
{
organizationAccountId: organization.account.id,
memberAccountId: admin.account.id,
role: "admin",
invitedById: admin.account.id,
accepted: new Date("2026-04-15T00:00:00.000Z"),
},
{
organizationAccountId: organization.account.id,
memberAccountId: member.account.id,
role: "member",
invitedById: admin.account.id,
accepted: new Date("2026-04-15T00:00:00.000Z"),
},
]);
await insertRemoteActor(tx, {
username: "orgoldalias",
name: "Org Old Alias",
host: "old.example",
iri: "https://old.example/users/orgoldalias",
url: "https://old.example/@orgoldalias",
});

const fedCtx = createFedCtx(tx);
fedCtx.getActor = (identifier: string) =>
Promise.resolve(
new vocab.Organization({ id: fedCtx.getActorUri(identifier) }),
);
const sentActivities: unknown[][] = [];
fedCtx.sendActivity = ((...args: unknown[]) => {
sentActivities.push(args);
return Promise.resolve(undefined);
}) as typeof fedCtx.sendActivity;

// An accepted admin can add an alias to the organization account.
const added = await execute({
schema,
document: addAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID("Account", organization.account.id),
actor: "@orgoldalias@old.example",
},
},
contextValue: makeUserContext(tx, admin.account, { fedCtx }),
onError: "NO_PROPAGATE",
});
assert.equal(added.errors, undefined);
assert.deepEqual(toPlainJson(added.data), {
addAccountMigrationAlias: {
__typename: "AddAccountMigrationAliasPayload",
account: {
actor: {
aliases: ["https://old.example/users/orgoldalias"],
},
},
},
});

// ... and remove it again.
const removed = await execute({
schema,
document: removeAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID("Account", organization.account.id),
alias: "https://old.example/users/orgoldalias",
},
},
contextValue: makeUserContext(tx, admin.account, { fedCtx }),
onError: "NO_PROPAGATE",
});
assert.equal(removed.errors, undefined);
assert.deepEqual(toPlainJson(removed.data), {
removeAccountMigrationAlias: {
__typename: "RemoveAccountMigrationAliasPayload",
account: {
actor: {
aliases: [],
},
},
},
});

// A non-admin member cannot manage the organization's migration aliases.
const byMember = await execute({
schema,
document: addAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID("Account", organization.account.id),
actor: "@orgoldalias@old.example",
},
},
contextValue: makeUserContext(tx, member.account, { fedCtx }),
onError: "NO_PROPAGATE",
});
assert.equal(byMember.errors, undefined);
assert.equal(
(toPlainJson(byMember.data) as {
addAccountMigrationAlias?: { __typename?: string };
}).addAccountMigrationAlias?.__typename,
"NotAuthorizedError",
);

// Neither can an account that is not a member at all.
const byOutsider = await execute({
schema,
document: addAccountMigrationAliasMutation,
variableValues: {
input: {
accountId: encodeGlobalID("Account", organization.account.id),
actor: "@orgoldalias@old.example",
},
},
contextValue: makeUserContext(tx, outsider.account, { fedCtx }),
onError: "NO_PROPAGATE",
});
assert.equal(byOutsider.errors, undefined);
assert.equal(
(toPlainJson(byOutsider.data) as {
addAccountMigrationAlias?: { __typename?: string };
}).addAccountMigrationAlias?.__typename,
"NotAuthorizedError",
);
});
});

test("deleteAccount deletes the viewer account and session", async () => {
await withRollback(async (tx) => {
const { kv } = createTestKv();
Expand Down
49 changes: 34 additions & 15 deletions graphql/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,12 +218,25 @@ async function getAuthorizedMigrationAccount(
throw new NotAuthenticatedError();
}
if (!validateUuid(accountId)) throw new InvalidInputError("accountId");
if (session.accountId !== accountId) throw new NotAuthorizedError();
const account = await ctx.db.query.accountTable.findFirst({
where: { id: accountId },
with: { actor: true },
});
if (account?.actor == null) throw new InvalidInputError("accountId");
// Treat a nonexistent account the same as an unauthorized one so the error
// does not leak whether the id exists (the input description promises that
// any account id the viewer cannot manage returns `NotAuthorizedError`).
if (
account == null ||
!(await viewerCanManageAccountSettings(
ctx,
account.id,
account.kind,
session.accountId,
))
) {
throw new NotAuthorizedError();
}
if (account.actor == null) throw new InvalidInputError("accountId");
return account;
}

Expand Down Expand Up @@ -1445,17 +1458,21 @@ builder.relayMutationField(
"addAccountMigrationAlias",
{
description:
"Add a previous account actor as an ActivityPub migration alias for " +
"the authenticated viewer's local account. The stored value is the " +
"resolved actor IRI, which is then advertised as `alsoKnownAs` on " +
"the local actor document so a later remote `Move` can be validated.",
"Add a previous account actor as an ActivityPub migration alias for a " +
"local account the authenticated viewer can manage: their own personal " +
"account, or an `ORGANIZATION` account they are an accepted `admin` of. " +
"The stored value is the resolved actor IRI, which is then advertised " +
"as `alsoKnownAs` on the local actor document so a later remote `Move` " +
"can be validated.",
inputFields: (t) => ({
accountId: t.globalID({
for: Account,
required: true,
description:
"The `Account` global id owned by the authenticated viewer. " +
"Passing another account id returns `NotAuthorizedError`.",
"The `Account` global id to add the alias to. The authenticated " +
"viewer must be able to manage that account: its personal holder, " +
"or an accepted `admin` of an `ORGANIZATION` account. Any other " +
"account id returns `NotAuthorizedError`.",
Comment thread
dahlia marked this conversation as resolved.
}),
actor: t.string({
required: true,
Expand Down Expand Up @@ -1506,17 +1523,19 @@ builder.relayMutationField(
"removeAccountMigrationAlias",
{
description:
"Remove one ActivityPub migration alias from the authenticated " +
"viewer's local account. Removing an alias only changes the new " +
"account's `alsoKnownAs` list; it does not send or retract a remote " +
"`Move` activity.",
"Remove one ActivityPub migration alias from a local account the " +
"authenticated viewer can manage: their own personal account, or an " +
"`ORGANIZATION` account they are an accepted `admin` of. Removing an " +
"alias only changes the account's `alsoKnownAs` list; it does not send " +
"or retract a remote `Move` activity.",
inputFields: (t) => ({
accountId: t.globalID({
for: Account,
required: true,
description:
"The `Account` global id owned by the authenticated viewer. " +
"Passing another account id returns `NotAuthorizedError`.",
description: "The `Account` global id to remove the alias from. The " +
"authenticated viewer must be able to manage that account: its " +
"personal holder, or an accepted `admin` of an `ORGANIZATION` " +
"account. Any other account id returns `NotAuthorizedError`.",
}),
alias: t.field({
type: "URL",
Expand Down
8 changes: 4 additions & 4 deletions graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -872,11 +872,11 @@ type ActorViewerInteractionsConnectionEdge {
}

"""
Add a previous account actor as an ActivityPub migration alias for the authenticated viewer's local account. The stored value is the resolved actor IRI, which is then advertised as `alsoKnownAs` on the local actor document so a later remote `Move` can be validated.
Add a previous account actor as an ActivityPub migration alias for a local account the authenticated viewer can manage: their own personal account, or an `ORGANIZATION` account they are an accepted `admin` of. The stored value is the resolved actor IRI, which is then advertised as `alsoKnownAs` on the local actor document so a later remote `Move` can be validated.
"""
input AddAccountMigrationAliasInput {
"""
The `Account` global id owned by the authenticated viewer. Passing another account id returns `NotAuthorizedError`.
The `Account` global id to add the alias to. The authenticated viewer must be able to manage that account: its personal holder, or an accepted `admin` of an `ORGANIZATION` account. Any other account id returns `NotAuthorizedError`.
"""
accountId: ID!

Expand Down Expand Up @@ -5287,11 +5287,11 @@ type RelaySubscription implements Node {
}

"""
Remove one ActivityPub migration alias from the authenticated viewer's local account. Removing an alias only changes the new account's `alsoKnownAs` list; it does not send or retract a remote `Move` activity.
Remove one ActivityPub migration alias from a local account the authenticated viewer can manage: their own personal account, or an `ORGANIZATION` account they are an accepted `admin` of. Removing an alias only changes the account's `alsoKnownAs` list; it does not send or retract a remote `Move` activity.
"""
input RemoveAccountMigrationAliasInput {
"""
The `Account` global id owned by the authenticated viewer. Passing another account id returns `NotAuthorizedError`.
The `Account` global id to remove the alias from. The authenticated viewer must be able to manage that account: its personal holder, or an accepted `admin` of an `ORGANIZATION` account. Any other account id returns `NotAuthorizedError`.
"""
accountId: ID!

Expand Down
8 changes: 4 additions & 4 deletions web-next/src/locales/en-US/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -5459,10 +5459,10 @@ msgstr "You can only view your own bookmarks"
msgid "You can only view your own drafts"
msgstr "You can only view your own drafts"

#: src/routes/(root)/[handle]/settings/account.tsx:1670
#: src/routes/(root)/[handle]/settings/account.tsx:1721
msgid "You can prepare migration only for your own account."
msgstr "You can prepare migration only for your own account."
#: src/routes/(root)/[handle]/settings/account.tsx:1676
#: src/routes/(root)/[handle]/settings/account.tsx:1727
msgid "You can prepare migration only for accounts you can manage."
msgstr "You can prepare migration only for accounts you can manage."

#: src/routes/(root)/[handle]/settings/account.tsx:979
msgid "You do not belong to any organizations yet."
Expand Down
8 changes: 4 additions & 4 deletions web-next/src/locales/ja-JP/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -5455,10 +5455,10 @@ msgstr "自分のブックマークのみ表示できます"
msgid "You can only view your own drafts"
msgstr "自分の下書きのみ表示できます"

#: src/routes/(root)/[handle]/settings/account.tsx:1670
#: src/routes/(root)/[handle]/settings/account.tsx:1721
msgid "You can prepare migration only for your own account."
msgstr "自分のアカウントでのみ移行を準備できます。"
#: src/routes/(root)/[handle]/settings/account.tsx:1676
#: src/routes/(root)/[handle]/settings/account.tsx:1727
msgid "You can prepare migration only for accounts you can manage."
msgstr "管理できるアカウントでのみ移行を準備できます。"

#: src/routes/(root)/[handle]/settings/account.tsx:979
msgid "You do not belong to any organizations yet."
Expand Down
8 changes: 4 additions & 4 deletions web-next/src/locales/ko-KR/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -5455,10 +5455,10 @@ msgstr "자신의 북마크만 볼 수 있습니다"
msgid "You can only view your own drafts"
msgstr "자신의 초고만 볼 수 있습니다"

#: src/routes/(root)/[handle]/settings/account.tsx:1670
#: src/routes/(root)/[handle]/settings/account.tsx:1721
msgid "You can prepare migration only for your own account."
msgstr "자신의 계정에서만 이전을 준비할 수 있습니다."
#: src/routes/(root)/[handle]/settings/account.tsx:1676
#: src/routes/(root)/[handle]/settings/account.tsx:1727
msgid "You can prepare migration only for accounts you can manage."
msgstr "관리할 수 있는 계정에서만 이전을 준비할 수 있습니다."

#: src/routes/(root)/[handle]/settings/account.tsx:979
msgid "You do not belong to any organizations yet."
Expand Down
8 changes: 4 additions & 4 deletions web-next/src/locales/zh-CN/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -5455,10 +5455,10 @@ msgstr "您只能查看自己的收藏"
msgid "You can only view your own drafts"
msgstr "您只能查看自己的草稿"

#: src/routes/(root)/[handle]/settings/account.tsx:1670
#: src/routes/(root)/[handle]/settings/account.tsx:1721
msgid "You can prepare migration only for your own account."
msgstr "你只能为自己的账户准备迁移。"
#: src/routes/(root)/[handle]/settings/account.tsx:1676
#: src/routes/(root)/[handle]/settings/account.tsx:1727
msgid "You can prepare migration only for accounts you can manage."
msgstr "你只能为你能管理的账户准备迁移。"

#: src/routes/(root)/[handle]/settings/account.tsx:979
msgid "You do not belong to any organizations yet."
Expand Down
8 changes: 4 additions & 4 deletions web-next/src/locales/zh-TW/messages.po
Original file line number Diff line number Diff line change
Expand Up @@ -5455,10 +5455,10 @@ msgstr "您只能檢視自己的收藏"
msgid "You can only view your own drafts"
msgstr "您只能檢視自己的草稿"

#: src/routes/(root)/[handle]/settings/account.tsx:1670
#: src/routes/(root)/[handle]/settings/account.tsx:1721
msgid "You can prepare migration only for your own account."
msgstr "你只能為自己的帳戶準備遷移。"
#: src/routes/(root)/[handle]/settings/account.tsx:1676
#: src/routes/(root)/[handle]/settings/account.tsx:1727
msgid "You can prepare migration only for accounts you can manage."
msgstr "你只能為你能管理的帳戶準備遷移。"

#: src/routes/(root)/[handle]/settings/account.tsx:979
msgid "You do not belong to any organizations yet."
Expand Down
Loading
Loading