toeverything/AFFiNE · error · LinkExpired

link_expired

link_expired

Error message

The link has expired.

What it means

The changePassword GraphQL mutation requires a userId argument (nullable in the signature for legacy reasons); when omitted, the resolver immediately throws LinkExpired (link_expired, 'The link has expired'). Current password-reset/set emails embed the userId in the link - a request without it is treated as coming from an outdated or hand-built link.

Solutions

  1. Always pass the userId exactly as provided alongside the token in the reset link URL
  2. Request a fresh password-reset email so you get the current link format
  3. Make sure frontend routing preserves both query parameters (userId and token) from the link
  4. Discard old bookmarked reset links

Example fix

# before
mutation {
  changePassword(token: $token, newPassword: $newPassword)
}

# after
mutation {
  changePassword(token: $token, newPassword: $newPassword, userId: $userId) # from the reset link's query string
}
Defensive patterns

Strategy: validation

Validate before calling

function parseResetLink(url: string): { userId: string; token: string } {
  const params = new URL(url).searchParams;
  const userId = params.get('userId');
  const token = params.get('token');
  if (!userId || !token) throw new Error('reset link is incomplete or outdated - request a new email');
  return { userId, token };
}

Try / catch

try {
  await changePassword({ token, newPassword, userId });
} catch (e) {
  if (isAffineErrorCode(e, 'link_expired')) {
    await requestPasswordReset(email); // fresh link carries both params
  } else throw e;
}

Prevention

When it happens

Trigger: Calling changePassword(token, newPassword) without userId; a password-reset link from an old email format that only carried the token; constructing the mutation by hand instead of following the link URL; frontend losing the userId query parameter when routing.

Common situations: Users clicking years-old reset emails; custom frontends that parse only the token from the link; email templates predating the userId requirement; deep links truncated by chat clients.

Related errors


AI-assisted analysis of toeverything/AFFiNE@b4c8548c09 (2026-08-18). Data as JSON: /api/errors/3ae56bcf6896a993. Report an issue: GitHub.

Appendix: source

Thrown at packages/backend/server/src/core/auth/resolver.ts:109

    const userSession = await this.auth.createUserSession(user.id);

    return {
      sessionToken: userSession.sessionId,
      token: userSession.sessionId,
      refresh: '',
    };
  }

  @Public()
  @Mutation(() => Boolean)
  async changePassword(
    @Args('token') token: string,
    @Args('newPassword') newPassword: string,
    @Args('userId', { type: () => String, nullable: true }) userId?: string
  ) {
    if (!userId) {
      throw new LinkExpired();
    }

    // NOTE: Set & Change password are using the same token type.
    const valid = await this.models.verificationToken.verify(
      TokenType.ChangePassword,
      token,
      {
        credential: userId,
      }
    );

    if (!valid) {
      throw new InvalidEmailToken();
    }

    await this.auth.changePasswordAndRevokeSessions(userId, newPassword);

    return true;

View on GitHub (pinned to b4c8548c09)